Compare commits
11 Commits
0ebc137369
...
trunk
| Author | SHA1 | Date | |
|---|---|---|---|
| 7837ab49b0 | |||
| 62c84aceaf | |||
| 7ac3882e9e | |||
| 129e9ccc5e | |||
| 8b290fdd0d | |||
| 845765513d | |||
| 857aefda03 | |||
| 7f651481f6 | |||
| 9b71fff4eb | |||
| 09e05c9f8e | |||
| 7128377924 |
@@ -1,3 +1,9 @@
|
||||
[workspace]
|
||||
members = ["client", "server"]
|
||||
resolver = "3"
|
||||
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
@@ -4,3 +4,7 @@ version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
bevy = { version = "0.17.2", features = ["serialize"] }
|
||||
rustls = "0.23.33"
|
||||
thiserror = "2.0.17"
|
||||
tungstenite = { version = "0.28.0", features = ["rustls-tls-webpki-roots", "rustls"] }
|
||||
|
||||
@@ -1,3 +1,298 @@
|
||||
use std::{io::ErrorKind, net::TcpStream, time::Duration};
|
||||
|
||||
use bevy::{
|
||||
ecs::world::CommandQueue,
|
||||
prelude::*,
|
||||
tasks::{AsyncComputeTaskPool, Task, block_on, futures_lite::future},
|
||||
};
|
||||
use thiserror::Error;
|
||||
use tungstenite::{WebSocket, http::Response, stream::MaybeTlsStream};
|
||||
|
||||
use crate::ui::{
|
||||
despawn_connection_wait_screen, despawn_main_menu, spawn_connection_wait_screen,
|
||||
spawn_main_menu,
|
||||
};
|
||||
|
||||
mod ui;
|
||||
|
||||
const PADDLE_GAP: f32 = 20.0; // gap between the paddles and the wall (window border)
|
||||
|
||||
fn main() {
|
||||
println!("Hello, world!");
|
||||
App::new()
|
||||
.add_plugins(DefaultPlugins)
|
||||
.insert_state(GameState::MainMenu)
|
||||
.add_systems(Startup, spawn_camera)
|
||||
.add_systems(OnEnter(GameState::MainMenu), spawn_main_menu)
|
||||
.add_systems(OnExit(GameState::MainMenu), despawn_main_menu)
|
||||
.add_observer(ui::button_hover_start)
|
||||
.add_observer(ui::button_hover_stop)
|
||||
.add_systems(
|
||||
OnEnter(GameState::Connecting),
|
||||
(
|
||||
spawn_connection_wait_screen,
|
||||
// Closure to immediately dispatch a setup-connection request.
|
||||
|mut messages: MessageWriter<WebSocketConnectionMessage>| {
|
||||
messages.write(WebSocketConnectionMessage::SetupConnection);
|
||||
},
|
||||
),
|
||||
)
|
||||
.add_systems(
|
||||
OnExit(GameState::Connecting),
|
||||
despawn_connection_wait_screen,
|
||||
)
|
||||
.add_systems(
|
||||
Update,
|
||||
(setup_connection, handle_tasks).run_if(in_state(GameState::Connecting)),
|
||||
)
|
||||
.add_systems(OnEnter(GameState::Playing), setup_game)
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
input_handler,
|
||||
setup_connection,
|
||||
handle_tasks,
|
||||
send_info,
|
||||
recv_info,
|
||||
)
|
||||
.run_if(in_state(GameState::ConnectionDemo)),
|
||||
)
|
||||
.add_message::<WebSocketConnectionMessage>()
|
||||
.insert_resource(ChatTimer {
|
||||
timer: Timer::new(Duration::from_secs(1), TimerMode::Repeating),
|
||||
})
|
||||
.run();
|
||||
}
|
||||
|
||||
/// Main game state indicator
|
||||
#[derive(Clone, Debug, Eq, Hash, PartialEq, States)]
|
||||
enum GameState {
|
||||
MainMenu,
|
||||
Connecting,
|
||||
Playing,
|
||||
ConnectionDemo, // TODO: Remove this state.
|
||||
}
|
||||
|
||||
/// Utility to despawn entities with a given component. Useful for scene
|
||||
/// changes.
|
||||
///
|
||||
/// I'm using this primarily with marker components to delete UI elements when
|
||||
/// they are no longer needed. E.g.: Removing the main menu after starting.
|
||||
pub fn despawn<T: Component>(mut commands: Commands, targets: Query<Entity, With<T>>) {
|
||||
targets
|
||||
.iter()
|
||||
.for_each(|entt| commands.entity(entt).despawn());
|
||||
}
|
||||
|
||||
fn spawn_camera(mut commands: Commands) {
|
||||
commands.spawn(Camera2d);
|
||||
}
|
||||
|
||||
/// Initialize the scene
|
||||
fn setup_game(
|
||||
mut commands: Commands,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
mut materials: ResMut<Assets<ColorMaterial>>,
|
||||
window: Single<&Window>,
|
||||
) {
|
||||
// ball
|
||||
commands.spawn((
|
||||
Ball,
|
||||
Mesh2d(meshes.add(Circle::new(10.0))),
|
||||
MeshMaterial2d(materials.add(Color::srgb(1.0, 0.0, 0.0))),
|
||||
Transform::default(),
|
||||
));
|
||||
|
||||
let paddle_mesh = meshes.add(Rectangle::new(10.0, 100.0));
|
||||
let paddle_material = materials.add(Color::WHITE);
|
||||
// Player 1
|
||||
commands.spawn((
|
||||
Paddle,
|
||||
Mesh2d(paddle_mesh.clone()),
|
||||
MeshMaterial2d(paddle_material.clone()),
|
||||
Transform::from_xyz(-window.width() / 2.0 + PADDLE_GAP, 0.0, 1.0),
|
||||
));
|
||||
// Player 2
|
||||
commands.spawn((
|
||||
Paddle,
|
||||
Mesh2d(paddle_mesh),
|
||||
MeshMaterial2d(paddle_material),
|
||||
Transform::from_xyz(window.width() / 2.0 - PADDLE_GAP, 0.0, 1.0),
|
||||
));
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
struct Ball;
|
||||
|
||||
/// Marker component for player paddles
|
||||
///
|
||||
/// Maybe one for each player?
|
||||
/// Maybe it can hold the WebSocket, too.
|
||||
/// *Maybe* I can have one struct with an Option<Ws> to know which
|
||||
/// player is the local one (the one with a socket).
|
||||
#[derive(Component)]
|
||||
struct Paddle;
|
||||
|
||||
/// ECS Component to hold the WebSocket. I guess there's going to be a magic
|
||||
/// entity that controls the networking.
|
||||
#[derive(Component)]
|
||||
struct WsClient(
|
||||
(
|
||||
WebSocket<MaybeTlsStream<TcpStream>>,
|
||||
Response<Option<Vec<u8>>>,
|
||||
),
|
||||
);
|
||||
|
||||
/// Container component for a bevy task. Specifically, the async Websocket
|
||||
/// setup.
|
||||
#[derive(Component)]
|
||||
struct WsSetupTask(Task<Result<CommandQueue, ConnSetupError>>);
|
||||
|
||||
/// Used to signal user input to other systems -- in particular, the notice to
|
||||
/// open a connection to the server.
|
||||
#[derive(Message)]
|
||||
enum WebSocketConnectionMessage {
|
||||
SetupConnection,
|
||||
// TODO: Presumably a TeardownConnection, right?
|
||||
}
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
enum ConnSetupError {
|
||||
#[error("IO")]
|
||||
Io(#[from] std::io::Error),
|
||||
#[error("WebSocket")]
|
||||
WebSocket(#[from] tungstenite::Error),
|
||||
}
|
||||
|
||||
/// The keyboard input handler (mainly to trigger WS connection)
|
||||
fn input_handler(
|
||||
input: Res<ButtonInput<KeyCode>>,
|
||||
mut messages: MessageWriter<WebSocketConnectionMessage>,
|
||||
) {
|
||||
if input.just_pressed(KeyCode::Space) {
|
||||
messages.write(WebSocketConnectionMessage::SetupConnection);
|
||||
}
|
||||
}
|
||||
|
||||
/// The system which establishes the WS connection. It responds to [`WebSocketConnectionMessage`]s.
|
||||
///
|
||||
/// When a [`WebSocketConnectionMessage::SetupConnection`] message is emitted,
|
||||
/// this system responds by creating a new (bevy) [`Task`]. This task is stored
|
||||
/// in the component [`WsSetupTask`] which is attached to a new entity.
|
||||
///
|
||||
/// The closure executed by this task attempts to reach out to a WebSocket URL.
|
||||
/// Upon successful connection, the socket is placed in a [`WsClient`]
|
||||
/// component and added to the same entity. At the same time, the [`WsSetupTask`]
|
||||
/// is removed since the task has now finished.
|
||||
///
|
||||
/// Monitoring for completion of the task is done by the system [`handle_tasks()`].
|
||||
fn setup_connection(
|
||||
mut commands: Commands,
|
||||
mut messages: MessageReader<WebSocketConnectionMessage>,
|
||||
) {
|
||||
for msg in messages.read() {
|
||||
match msg {
|
||||
WebSocketConnectionMessage::SetupConnection => {
|
||||
info!("Connecting to server...");
|
||||
let url = "ws://localhost:4000/websocket";
|
||||
let entity = commands.spawn_empty().id();
|
||||
{
|
||||
let pool = AsyncComputeTaskPool::get();
|
||||
let task = pool.spawn(async move {
|
||||
let mut client = tungstenite::connect(url)?;
|
||||
match client.0.get_mut() {
|
||||
MaybeTlsStream::Plain(p) => p.set_nonblocking(true)?,
|
||||
MaybeTlsStream::Rustls(stream_owned) => {
|
||||
stream_owned.get_mut().set_nonblocking(true)?
|
||||
}
|
||||
_ => todo!(),
|
||||
};
|
||||
|
||||
info!("Connected!");
|
||||
// First message is this client's username. I'll hard-code
|
||||
// a value for this PoC.
|
||||
client.0.send(tungstenite::Message::Text("Dingus".into()))?;
|
||||
let mut command_queue = CommandQueue::default();
|
||||
|
||||
command_queue.push(move |world: &mut World| {
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.insert(WsClient(client))
|
||||
.remove::<WsSetupTask>();
|
||||
});
|
||||
|
||||
Ok(command_queue)
|
||||
});
|
||||
commands.entity(entity).insert(WsSetupTask(task));
|
||||
}
|
||||
} // _ => { there are no other variants right now, so this is pretty silly }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Polls the websocket setup task(s) to check for progress.
|
||||
///
|
||||
/// If the task has succeeded, it will give back a [`CommandQueue`]. This is
|
||||
/// appended to the current [`Commands`] so they are acted upon this tick.
|
||||
///
|
||||
/// If it has failed, an error is printed to the console.
|
||||
///
|
||||
/// The task is self-removing, so we don't need to delete the [`WsSetupTask`]
|
||||
/// component here.
|
||||
fn handle_tasks(
|
||||
mut commands: Commands,
|
||||
mut transform_tasks: Query<&mut WsSetupTask>,
|
||||
mut states: ResMut<NextState<GameState>>,
|
||||
) {
|
||||
for mut task in &mut transform_tasks {
|
||||
if let Some(result) = block_on(future::poll_once(&mut task.0)) {
|
||||
match result {
|
||||
Ok(mut commands_queue) => {
|
||||
commands.append(&mut commands_queue);
|
||||
states.set(GameState::Playing);
|
||||
}
|
||||
Err(e) => info!("Connection failed. Err: {e:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct ChatTimer {
|
||||
timer: Timer,
|
||||
}
|
||||
|
||||
/// Unused, left over from example code.
|
||||
///
|
||||
/// It *would* tick a timer, sending a message to the echo server each time the
|
||||
/// timer expires. But I have a chatroom not an echo server, so this bit
|
||||
/// doesn't make sense.
|
||||
///
|
||||
/// If I were to make a full chat client, I would replace this with some kind
|
||||
/// of event handler.
|
||||
fn send_info(
|
||||
some_data: Query<(&Transform,)>,
|
||||
time: Res<Time>,
|
||||
mut client_entt: Query<(&mut WsClient,)>,
|
||||
mut cfg: ResMut<ChatTimer>,
|
||||
) {
|
||||
// cfg.timer.tick(time.delta());
|
||||
// if cfg.timer.just_finished() {
|
||||
// info!("Sending username to server.");
|
||||
// for
|
||||
// }
|
||||
}
|
||||
|
||||
/// Reads & prints all messages from all websockets in [`WsClient`]s.
|
||||
///
|
||||
/// For a real chat app, this would need to print to the screen somehow. I'd
|
||||
/// need a lot of other things, too, and this is supposed to be Pong. So I'm
|
||||
/// not going to fix any of that.
|
||||
fn recv_info(mut q: Query<&mut WsClient>) {
|
||||
for mut client in q.iter_mut() {
|
||||
match client.0.0.read() {
|
||||
Ok(m) => info!("Received message {m:?}"),
|
||||
Err(tungstenite::Error::Io(e)) if e.kind() == ErrorKind::WouldBlock => { /* ignore */ }
|
||||
Err(e) => warn!("error receiving: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
client/src/ui.rs
Normal file
126
client/src/ui.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
//! All the UI elements for the Pong game client.
|
||||
|
||||
use bevy::{
|
||||
color::palettes::css::{DARK_GRAY, GRAY},
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use crate::GameState;
|
||||
|
||||
pub const BTN_BORDER_COLOR: Color = Color::WHITE;
|
||||
pub const BTN_BG_COLOR: Color = Color::BLACK;
|
||||
pub const BTN_BG_SELECTED: Color = bevy::prelude::Color::Srgba(DARK_GRAY);
|
||||
|
||||
pub fn spawn_main_menu(mut commands: Commands) {
|
||||
commands
|
||||
.spawn((Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
flex_direction: FlexDirection::Column,
|
||||
..Default::default()
|
||||
},))
|
||||
.with_children(|cmds| {
|
||||
cmds.spawn((
|
||||
// TODO: A more sharp and square font, maybe "pixel art" bitmap
|
||||
// to really get the chunky feel of Pong.
|
||||
Text::new("Robert's Bad Pong Game"),
|
||||
TextFont::from_font_size(50.0),
|
||||
TextLayout::new_with_justify(Justify::Center),
|
||||
TextShadow::default(),
|
||||
));
|
||||
let mut start_button = cmds.spawn(button_bundle("Start game"));
|
||||
start_button.observe(
|
||||
|_trigger: On<Pointer<Click>>, mut game_state: ResMut<NextState<GameState>>| {
|
||||
game_state.set(GameState::Connecting);
|
||||
},
|
||||
);
|
||||
let mut quit_button = cmds.spawn(button_bundle("Quit Game"));
|
||||
quit_button.observe(
|
||||
|_trigger: On<Pointer<Click>>, mut messages: MessageWriter<AppExit>| {
|
||||
// Quit the game if the quit button was pressed.
|
||||
messages.write(AppExit::Success);
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Despawns the main menu (which is assumed to be the top-most node)
|
||||
///
|
||||
/// TODO: Add a marker component, but only in debug builds. A unit test can
|
||||
/// assert this condition so I don't actually have to check it at runtime
|
||||
/// on release builds.
|
||||
pub fn despawn_main_menu(
|
||||
mut commands: Commands,
|
||||
top_node: Single<Entity, (With<Node>, Without<ChildOf>)>,
|
||||
) {
|
||||
commands.entity(top_node.into_inner()).despawn();
|
||||
}
|
||||
|
||||
pub fn spawn_connection_wait_screen(mut commands: Commands) {
|
||||
info!("Spawning connecting notice.");
|
||||
commands.spawn((
|
||||
Node {
|
||||
width: Val::Percent(100.0),
|
||||
height: Val::Percent(100.0),
|
||||
align_items: AlignItems::Center,
|
||||
justify_content: JustifyContent::Center,
|
||||
flex_direction: FlexDirection::Column,
|
||||
..Default::default()
|
||||
},
|
||||
children![Text::new("Connecting...")],
|
||||
));
|
||||
}
|
||||
|
||||
pub fn despawn_connection_wait_screen(
|
||||
mut commands: Commands,
|
||||
text_nodes: Single<Entity, (With<Node>, With<Text>)>,
|
||||
) {
|
||||
info!("Despawning connecting notice.");
|
||||
commands.entity(text_nodes.into_inner()).despawn();
|
||||
}
|
||||
|
||||
/// The basic bundle for generic buttons.
|
||||
///
|
||||
/// It's mostly so I don't have to copy & paste this everywhere I want to use it.
|
||||
fn button_bundle(text: &str) -> impl Bundle {
|
||||
(
|
||||
Button,
|
||||
Node {
|
||||
width: Val::Px(150.0),
|
||||
height: Val::Px(65.0),
|
||||
border: UiRect::all(Val::Px(4.0)),
|
||||
justify_content: JustifyContent::Center,
|
||||
align_items: AlignItems::Center,
|
||||
margin: UiRect::all(Val::Px(10.0)),
|
||||
..Default::default()
|
||||
},
|
||||
BorderColor::all(BTN_BORDER_COLOR),
|
||||
BorderRadius::ZERO,
|
||||
BackgroundColor(BTN_BG_COLOR),
|
||||
children![(
|
||||
Text::new(text),
|
||||
TextColor(GRAY.into()),
|
||||
TextShadow::default(),
|
||||
)],
|
||||
)
|
||||
}
|
||||
|
||||
pub fn button_hover_start(
|
||||
trigger: On<Pointer<Over>>,
|
||||
mut button_colors: Query<(&mut BackgroundColor, &mut BorderColor), With<Button>>,
|
||||
) {
|
||||
if let Ok((mut bg, mut _border)) = button_colors.get_mut(trigger.entity) {
|
||||
bg.0 = BTN_BG_SELECTED;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn button_hover_stop(
|
||||
trigger: On<Pointer<Out>>,
|
||||
mut button_colors: Query<(&mut BackgroundColor, &mut BorderColor), With<Button>>,
|
||||
) {
|
||||
if let Ok((mut bg, mut _border)) = button_colors.get_mut(trigger.entity) {
|
||||
bg.0 = BTN_BG_COLOR;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,6 @@ async fn websocket(socket: WebSocket, state: Arc<AppState>) {
|
||||
let mut rx = state.tx.subscribe();
|
||||
let _ = state.tx.send(format!("{username} joined the lobby!"));
|
||||
|
||||
|
||||
// Read messages broadcast through the server, write them to this socket.
|
||||
// If any error is returned, break the loop to terminate the task. We're
|
||||
// not dealing with them right now.
|
||||
@@ -111,18 +110,18 @@ async fn websocket(socket: WebSocket, state: Arc<AppState>) {
|
||||
}
|
||||
|
||||
/// Sets the requested username into buffer `name_out` if it is currently unused in the lobby.
|
||||
///
|
||||
///
|
||||
/// Check for presence of `name` in `state.user_set`. If taken, the `name_out` out-parameter
|
||||
/// is left unchanged (which should be empty, signaling to the caller that the name is
|
||||
/// unavailable).
|
||||
///
|
||||
/// If the name is available, it is added to `state.user_set` (thus making it unavailable
|
||||
/// going forward) and written into the `name_out` buffer for use by the caller (non-empty
|
||||
/// values signal that the name has been accepted for use).
|
||||
/// values signal that the name has been accepted for use).
|
||||
fn check_username(state: &AppState, name_out: &mut String, name: &str) {
|
||||
// TODO: Return a Result instead of using out-parameters. This isn't C,
|
||||
// we can do better.
|
||||
let mut user_set= state.user_set.lock().unwrap();
|
||||
let mut user_set = state.user_set.lock().unwrap();
|
||||
if !user_set.contains(name) {
|
||||
user_set.insert(name.to_owned());
|
||||
name_out.push_str(name);
|
||||
|
||||
Reference in New Issue
Block a user