chore: integrate multiplayer code (wip)
This commit is contained in:
parent
2838d59f30
commit
4f5e21becb
66 changed files with 6423 additions and 18 deletions
84
clients/backbone-lib/src/client.rs
Normal file
84
clients/backbone-lib/src/client.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//! Background task for the client (non-host) side of a session.
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{parse_client_update, send_disconnect, send_rpc};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::SerializationCap;
|
||||
|
||||
pub(crate) async fn client_loop<A, D, VS>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap,
|
||||
VS: SerializationCap,
|
||||
{
|
||||
loop {
|
||||
// 1. Drain outbound actions.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
send_rpc(&mut ws_sender, &action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
send_disconnect(&mut ws_sender, false);
|
||||
return;
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain inbound state updates.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_client_update::<VS, D>(data) {
|
||||
Ok(updates) => {
|
||||
for u in updates {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(u))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
211
clients/backbone-lib/src/host.rs
Normal file
211
clients/backbone-lib/src/host.rs
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
//! Background task for the host (game server) side of a session.
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage, WsReceiver, WsSender};
|
||||
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender};
|
||||
use web_time::{Duration, Instant};
|
||||
|
||||
use crate::platform::sleep_ms;
|
||||
use crate::protocol::{
|
||||
ToServerCommand, parse_server_command, send_delta, send_disconnect, send_full_state,
|
||||
send_kick, send_reset,
|
||||
};
|
||||
use crate::session::{BackendMsg, SessionEvent};
|
||||
use crate::traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
|
||||
struct Timer {
|
||||
id: u16,
|
||||
fire_at: Instant,
|
||||
}
|
||||
|
||||
pub(crate) async fn host_loop<A, D, VS, Backend>(
|
||||
mut ws_sender: WsSender,
|
||||
ws_receiver: WsReceiver,
|
||||
mut action_rx: UnboundedReceiver<BackendMsg<A>>,
|
||||
event_tx: UnboundedSender<SessionEvent<D, VS>>,
|
||||
rule_variation: u16,
|
||||
host_state: Option<Vec<u8>>,
|
||||
) where
|
||||
A: SerializationCap,
|
||||
D: SerializationCap + Clone,
|
||||
VS: SerializationCap + Clone,
|
||||
Backend: BackEndArchitecture<A, D, VS>,
|
||||
{
|
||||
let mut backend = host_state
|
||||
.as_deref()
|
||||
.and_then(|b| Backend::from_bytes(rule_variation, b))
|
||||
.unwrap_or_else(|| Backend::new(rule_variation));
|
||||
backend.player_arrival(0);
|
||||
|
||||
// Push initial state to UI immediately.
|
||||
let initial = backend.get_view_state().clone();
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(initial)))
|
||||
.ok();
|
||||
|
||||
let mut timers: Vec<Timer> = Vec::new();
|
||||
let mut cancelled_timers: HashSet<u16> = HashSet::new();
|
||||
let mut remote_player_count: u16 = 0;
|
||||
|
||||
loop {
|
||||
let mut client_joined = false;
|
||||
|
||||
// 1. Drain local actions / detect session drop or disconnect request.
|
||||
loop {
|
||||
match action_rx.try_next() {
|
||||
Ok(Some(BackendMsg::Action(action))) => {
|
||||
backend.inform_rpc(0, action);
|
||||
}
|
||||
Ok(Some(BackendMsg::Disconnect)) => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Ok(None) => {
|
||||
// All senders dropped — session was dropped without calling disconnect().
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
return;
|
||||
}
|
||||
Err(_) => break, // Channel empty; nothing pending.
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Drain WebSocket events from the relay.
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
match parse_server_command::<A>(data) {
|
||||
ToServerCommand::ClientJoin(id) => {
|
||||
backend.player_arrival(id);
|
||||
remote_player_count += 1;
|
||||
client_joined = true;
|
||||
}
|
||||
ToServerCommand::ClientLeft(id) => {
|
||||
backend.player_departure(id);
|
||||
remote_player_count = remote_player_count.saturating_sub(1);
|
||||
}
|
||||
ToServerCommand::Rpc(id, payload) => {
|
||||
backend.inform_rpc(id, payload);
|
||||
}
|
||||
ToServerCommand::Error(e) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(WsEvent::Closed) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(
|
||||
"Connection closed".to_string(),
|
||||
)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(Some(e)))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
Some(_) => continue, // Ignore Opened / text messages.
|
||||
None => break, // No more events this iteration.
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fire elapsed timers.
|
||||
let now = Instant::now();
|
||||
let mut fired = Vec::new();
|
||||
timers.retain(|t| {
|
||||
if t.fire_at <= now {
|
||||
fired.push(t.id);
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
for id in fired {
|
||||
if !cancelled_timers.remove(&id) {
|
||||
backend.timer_triggered(id);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Drain and process backend commands.
|
||||
let commands = backend.drain_commands();
|
||||
|
||||
if commands.is_empty() && !client_joined {
|
||||
sleep_ms(2).await;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut delta_batch: Vec<D> = Vec::new();
|
||||
let mut reset = false;
|
||||
|
||||
for cmd in commands {
|
||||
match cmd {
|
||||
BackendCommand::TerminateRoom => {
|
||||
send_disconnect(&mut ws_sender, true);
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Disconnected(None))
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
BackendCommand::SetTimer { timer_id, duration } => {
|
||||
// Cancel any existing timer with the same id, then re-arm.
|
||||
timers.retain(|t| t.id != timer_id);
|
||||
cancelled_timers.remove(&timer_id);
|
||||
timers.push(Timer {
|
||||
id: timer_id,
|
||||
fire_at: Instant::now() + Duration::from_secs_f32(duration),
|
||||
});
|
||||
}
|
||||
BackendCommand::CancelTimer { timer_id } => {
|
||||
cancelled_timers.insert(timer_id);
|
||||
}
|
||||
BackendCommand::KickPlayer { player } => {
|
||||
if remote_player_count > 0 {
|
||||
send_kick(&mut ws_sender, player);
|
||||
}
|
||||
}
|
||||
BackendCommand::ResetViewState => {
|
||||
reset = true;
|
||||
}
|
||||
BackendCommand::Delta(d) => {
|
||||
delta_batch.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if reset {
|
||||
// Reset supersedes all pending deltas: send fresh full state.
|
||||
let state = backend.get_view_state().clone();
|
||||
if remote_player_count > 0 {
|
||||
send_reset(&mut ws_sender, &state);
|
||||
}
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Full(state)))
|
||||
.ok();
|
||||
} else {
|
||||
// Broadcast deltas, then notify local UI.
|
||||
if remote_player_count > 0 && !delta_batch.is_empty() {
|
||||
send_delta(&mut ws_sender, &delta_batch);
|
||||
}
|
||||
for d in delta_batch {
|
||||
event_tx
|
||||
.unbounded_send(SessionEvent::Update(ViewStateUpdate::Incremental(d)))
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// Send full state to clients that joined this iteration.
|
||||
if client_joined {
|
||||
send_full_state(&mut ws_sender, backend.get_view_state());
|
||||
}
|
||||
|
||||
sleep_ms(2).await;
|
||||
}
|
||||
}
|
||||
10
clients/backbone-lib/src/lib.rs
Normal file
10
clients/backbone-lib/src/lib.rs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
pub mod session;
|
||||
pub mod traits;
|
||||
|
||||
mod client;
|
||||
mod host;
|
||||
mod platform;
|
||||
mod protocol;
|
||||
|
||||
pub use session::{ConnectError, GameSession, RoomConfig, RoomRole, SessionEvent};
|
||||
pub use traits::{BackEndArchitecture, BackendCommand, SerializationCap, ViewStateUpdate};
|
||||
48
clients/backbone-lib/src/platform.rs
Normal file
48
clients/backbone-lib/src/platform.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
use std::future::Future;
|
||||
|
||||
/// Spawns a background task.
|
||||
/// - WASM: uses `wasm_bindgen_futures::spawn_local` (no Send required)
|
||||
/// - Native: spawns an OS thread running `futures::executor::block_on`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + 'static,
|
||||
{
|
||||
wasm_bindgen_futures::spawn_local(fut);
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub fn spawn_task<F>(fut: F)
|
||||
where
|
||||
F: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
std::thread::spawn(move || {
|
||||
futures::executor::block_on(fut);
|
||||
});
|
||||
}
|
||||
|
||||
/// Yields for approximately `ms` milliseconds.
|
||||
/// - WASM: non-blocking yield via browser timer
|
||||
/// - Native: blocks the current thread (safe on a dedicated background thread)
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
gloo_timers::future::TimeoutFuture::new(ms).await;
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub async fn sleep_ms(ms: u32) {
|
||||
std::thread::sleep(std::time::Duration::from_millis(ms as u64));
|
||||
}
|
||||
|
||||
/// Platform-agnostic bound for types that can be moved into a background task.
|
||||
/// - WASM: only requires `'static` (single-threaded, no Send needed)
|
||||
/// - Native: requires `Send + 'static`
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub trait TaskBound: 'static {}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
impl<T: 'static> TaskBound for T {}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub trait TaskBound: Send + 'static {}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
impl<T: Send + 'static> TaskBound for T {}
|
||||
159
clients/backbone-lib/src/protocol.rs
Normal file
159
clients/backbone-lib/src/protocol.rs
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
//! Wire protocol encoding/decoding helpers.
|
||||
//!
|
||||
//! Translates between raw WebSocket binary frames and typed Rust values using
|
||||
//! postcard serialization and the message-type constants from the `protocol` crate.
|
||||
|
||||
use crate::traits::{SerializationCap, ViewStateUpdate};
|
||||
use bytes::{Buf, BufMut, Bytes, BytesMut};
|
||||
use ewebsock::{WsMessage, WsSender};
|
||||
use postcard::{from_bytes, take_from_bytes, to_stdvec};
|
||||
use protocol::{
|
||||
CLIENT_DISCONNECTS, CLIENT_DISCONNECTS_SELF, CLIENT_GETS_KICKED, CLIENT_ID_SIZE, DELTA_UPDATE,
|
||||
FULL_UPDATE, HAND_SHAKE_RESPONSE, JoinRequest, NEW_CLIENT, RESET, SERVER_DISCONNECTS,
|
||||
SERVER_ERROR, SERVER_RPC,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Inbound command types (relay → host)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub enum ToServerCommand<A> {
|
||||
ClientJoin(u16),
|
||||
ClientLeft(u16),
|
||||
Rpc(u16, A),
|
||||
Error(String),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Send helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn send_binary(sender: &mut WsSender, data: &[u8]) {
|
||||
sender.send(WsMessage::Binary(data.to_vec()));
|
||||
}
|
||||
|
||||
pub fn send_join_request(sender: &mut WsSender, req: &JoinRequest) -> Result<(), String> {
|
||||
let bytes = to_stdvec(req).map_err(|e| e.to_string())?;
|
||||
send_binary(sender, &bytes);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn send_rpc<A: SerializationCap>(sender: &mut WsSender, action: &A) {
|
||||
let raw = to_stdvec(action).expect("Failed to serialize RPC");
|
||||
let mut buf = BytesMut::with_capacity(1 + raw.len());
|
||||
buf.put_u8(SERVER_RPC);
|
||||
buf.put_slice(&raw);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_delta<D: SerializationCap>(sender: &mut WsSender, deltas: &[D]) {
|
||||
let serialized: Vec<u8> = deltas
|
||||
.iter()
|
||||
.flat_map(|d| to_stdvec(d).expect("Failed to serialize delta"))
|
||||
.collect();
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(DELTA_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_full_state<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize full state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(FULL_UPDATE);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_reset<VS: SerializationCap>(sender: &mut WsSender, state: &VS) {
|
||||
let serialized = to_stdvec(state).expect("Failed to serialize reset state");
|
||||
let mut buf = BytesMut::with_capacity(1 + serialized.len());
|
||||
buf.put_u8(RESET);
|
||||
buf.put_slice(&serialized);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_kick(sender: &mut WsSender, player_id: u16) {
|
||||
let mut buf = BytesMut::with_capacity(1 + CLIENT_ID_SIZE);
|
||||
buf.put_u8(CLIENT_GETS_KICKED);
|
||||
buf.put_u16(player_id);
|
||||
send_binary(sender, &buf);
|
||||
}
|
||||
|
||||
pub fn send_disconnect(sender: &mut WsSender, as_host: bool) {
|
||||
let msg = if as_host {
|
||||
SERVER_DISCONNECTS
|
||||
} else {
|
||||
CLIENT_DISCONNECTS_SELF
|
||||
};
|
||||
send_binary(sender, &[msg]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Receive / parse helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parses the relay's handshake response.
|
||||
///
|
||||
/// Returns `(player_id, rule_variation, reconnect_token)`.
|
||||
pub fn parse_handshake_response(data: Vec<u8>) -> Result<(u16, u16, u64), String> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
HAND_SHAKE_RESPONSE => {
|
||||
let player_id = bytes.get_u16();
|
||||
let rule_variation = bytes.get_u16();
|
||||
let token = bytes.get_u64();
|
||||
Ok((player_id, rule_variation, token))
|
||||
}
|
||||
other => Err(format!("Unexpected handshake message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_server_command<A: SerializationCap>(data: Vec<u8>) -> ToServerCommand<A> {
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => ToServerCommand::Error(String::from_utf8_lossy(&bytes).to_string()),
|
||||
NEW_CLIENT => ToServerCommand::ClientJoin(bytes.get_u16()),
|
||||
CLIENT_DISCONNECTS => ToServerCommand::ClientLeft(bytes.get_u16()),
|
||||
SERVER_RPC => {
|
||||
let client_id = bytes.get_u16();
|
||||
let payload: A =
|
||||
from_bytes(bytes.chunk()).expect("Failed to deserialize server RPC payload");
|
||||
ToServerCommand::Rpc(client_id, payload)
|
||||
}
|
||||
other => ToServerCommand::Error(format!("Unknown server message id: {other}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_client_update<VS, D>(
|
||||
data: Vec<u8>,
|
||||
) -> Result<Vec<ViewStateUpdate<VS, D>>, String>
|
||||
where
|
||||
VS: SerializationCap,
|
||||
D: SerializationCap,
|
||||
{
|
||||
let mut bytes = Bytes::from(data);
|
||||
let msg = bytes.get_u8();
|
||||
match msg {
|
||||
SERVER_ERROR => Err(String::from_utf8_lossy(&bytes).to_string()),
|
||||
DELTA_UPDATE => {
|
||||
let mut result = Vec::new();
|
||||
let mut remaining: &[u8] = &bytes;
|
||||
while !remaining.is_empty() {
|
||||
let (delta, rest): (D, &[u8]) =
|
||||
take_from_bytes(remaining).map_err(|e| e.to_string())?;
|
||||
remaining = rest;
|
||||
result.push(ViewStateUpdate::Incremental(delta));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
FULL_UPDATE | RESET => {
|
||||
let state: VS = from_bytes(&bytes).map_err(|e| e.to_string())?;
|
||||
Ok(vec![ViewStateUpdate::Full(state)])
|
||||
}
|
||||
other => Err(format!("Unknown client message id: {other}")),
|
||||
}
|
||||
}
|
||||
266
clients/backbone-lib/src/session.rs
Normal file
266
clients/backbone-lib/src/session.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
//! The public-facing session API.
|
||||
//!
|
||||
//! # Usage
|
||||
//!
|
||||
//! ```ignore
|
||||
//! // Connect (async, returns after handshake completes)
|
||||
//! let mut session: GameSession<MyAction, MyDelta, MyState> =
|
||||
//! GameSession::connect::<MyBackend>(RoomConfig {
|
||||
//! relay_url: "ws://localhost:8080/ws".to_string(),
|
||||
//! game_id: "my-game".to_string(),
|
||||
//! room_id: "room-42".to_string(),
|
||||
//! rule_variation: 0,
|
||||
//! role: RoomRole::Create,
|
||||
//! reconnect_token: None,
|
||||
//! })
|
||||
//! .await?;
|
||||
//!
|
||||
//! // In a loop (e.g. Dioxus coroutine with futures::select!):
|
||||
//! loop {
|
||||
//! futures::select! {
|
||||
//! cmd = ui_rx.next().fuse() => session.send_action(cmd),
|
||||
//! event = session.next_event().fuse() => match event {
|
||||
//! Some(SessionEvent::Update(u)) => view_state.apply(u),
|
||||
//! Some(SessionEvent::Disconnected(reason)) | None => break,
|
||||
//! }
|
||||
//! }
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
use ewebsock::{WsEvent, WsMessage};
|
||||
use futures::StreamExt;
|
||||
use futures::channel::mpsc::{self, UnboundedReceiver, UnboundedSender};
|
||||
use protocol::JoinRequest;
|
||||
|
||||
use crate::client::client_loop;
|
||||
use crate::host::host_loop;
|
||||
use crate::platform::{TaskBound, sleep_ms, spawn_task};
|
||||
use crate::protocol::{parse_handshake_response, send_join_request};
|
||||
use crate::traits::{BackEndArchitecture, SerializationCap, ViewStateUpdate};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public configuration types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Whether to create a new room (host) or join an existing one (client).
|
||||
pub enum RoomRole {
|
||||
Create,
|
||||
Join,
|
||||
}
|
||||
|
||||
/// Configuration required to connect to a game session.
|
||||
pub struct RoomConfig {
|
||||
/// WebSocket URL of the relay server (e.g. `"ws://localhost:8080/ws"`).
|
||||
pub relay_url: String,
|
||||
/// Game identifier registered on the relay (e.g. `"tic-tac-toe"`).
|
||||
pub game_id: String,
|
||||
/// Room identifier shared between host and clients.
|
||||
pub room_id: String,
|
||||
/// Game mode/variant. Only used when `role` is `Create`.
|
||||
pub rule_variation: u16,
|
||||
pub role: RoomRole,
|
||||
/// If `Some`, attempt to reconnect to an existing session instead of creating/joining fresh.
|
||||
/// The value is the token returned by a previous successful handshake.
|
||||
pub reconnect_token: Option<u64>,
|
||||
/// Serialized backend state for host reconnect.
|
||||
///
|
||||
/// Produced by the app layer (e.g. `serde_json::to_vec(&view_state)`) and stored in
|
||||
/// localStorage. Passed to [`BackEndArchitecture::from_bytes`] when the host
|
||||
/// reconnects so the game can resume from the last known state.
|
||||
/// Ignored for non-host reconnects and normal connections.
|
||||
pub host_state: Option<Vec<u8>>,
|
||||
}
|
||||
|
||||
/// Error returned by [`GameSession::connect`].
|
||||
#[derive(Debug)]
|
||||
pub enum ConnectError {
|
||||
WebSocket(String),
|
||||
Handshake(String),
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ConnectError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ConnectError::WebSocket(e) => write!(f, "WebSocket error: {e}"),
|
||||
ConnectError::Handshake(e) => write!(f, "Handshake error: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal message type (UI → background task)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
pub(crate) enum BackendMsg<A> {
|
||||
Action(A),
|
||||
Disconnect,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session event (background task → UI)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Events emitted by the session to the UI.
|
||||
pub enum SessionEvent<Delta, ViewState> {
|
||||
/// A state update arrived from the host backend.
|
||||
Update(ViewStateUpdate<ViewState, Delta>),
|
||||
/// The session ended. `None` = clean disconnect, `Some(reason)` = error.
|
||||
Disconnected(Option<String>),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GameSession
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A connected game session.
|
||||
///
|
||||
/// Created by [`GameSession::connect`]. Holds channels to the background task
|
||||
/// that owns the WebSocket connection and (on host) the game backend.
|
||||
pub struct GameSession<Action, Delta, ViewState> {
|
||||
/// The player ID assigned by the relay server. Always `0` for the host.
|
||||
pub player_id: u16,
|
||||
/// The game mode/variant selected by the host.
|
||||
pub rule_variation: u16,
|
||||
/// `true` if this client is hosting the game (runs the backend).
|
||||
pub is_host: bool,
|
||||
/// Token to persist in localStorage for reconnect on page refresh.
|
||||
/// Only meaningful for non-host players (player_id > 0).
|
||||
pub reconnect_token: u64,
|
||||
action_tx: UnboundedSender<BackendMsg<Action>>,
|
||||
event_rx: UnboundedReceiver<SessionEvent<Delta, ViewState>>,
|
||||
}
|
||||
|
||||
impl<A, D, VS> GameSession<A, D, VS>
|
||||
where
|
||||
A: SerializationCap + TaskBound,
|
||||
D: SerializationCap + Clone + TaskBound,
|
||||
VS: SerializationCap + Clone + TaskBound,
|
||||
{
|
||||
/// Connects to the relay server and performs the handshake.
|
||||
///
|
||||
/// Returns after the relay confirms the player ID and rule variation.
|
||||
/// Spawns a background task that drives the WebSocket connection for the
|
||||
/// lifetime of the session.
|
||||
///
|
||||
/// # Errors
|
||||
/// Returns `Err` if the WebSocket cannot be opened or the handshake fails.
|
||||
pub async fn connect<Backend>(config: RoomConfig) -> Result<Self, ConnectError>
|
||||
where
|
||||
Backend: BackEndArchitecture<A, D, VS> + TaskBound,
|
||||
{
|
||||
let create_room = matches!(config.role, RoomRole::Create);
|
||||
|
||||
// 1. Open WebSocket.
|
||||
let (mut ws_sender, ws_receiver) =
|
||||
ewebsock::connect(&config.relay_url, ewebsock::Options::default())
|
||||
.map_err(|e| ConnectError::WebSocket(e.to_string()))?;
|
||||
|
||||
// 2. Wait for the Opened event (WASM WebSocket is async).
|
||||
loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Opened) => break,
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::WebSocket(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
return Err(ConnectError::WebSocket("Connection closed".to_string()));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Send the join request.
|
||||
let req = JoinRequest {
|
||||
game_id: config.game_id,
|
||||
room_id: config.room_id,
|
||||
rule_variation: config.rule_variation,
|
||||
create_room,
|
||||
reconnect_token: config.reconnect_token,
|
||||
};
|
||||
send_join_request(&mut ws_sender, &req).map_err(ConnectError::Handshake)?;
|
||||
|
||||
// 4. Wait for the handshake response.
|
||||
let (player_id, rule_variation, reconnect_token) = loop {
|
||||
match ws_receiver.try_recv() {
|
||||
Some(WsEvent::Message(WsMessage::Binary(data))) => {
|
||||
break parse_handshake_response(data).map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
Some(WsEvent::Error(e)) => return Err(ConnectError::Handshake(e)),
|
||||
Some(WsEvent::Closed) => {
|
||||
// The relay may have sent a binary error frame just before
|
||||
// closing. ewebsock can deliver Closed before that frame,
|
||||
// so drain one more message to catch it.
|
||||
if let Some(WsEvent::Message(WsMessage::Binary(data))) =
|
||||
ws_receiver.try_recv()
|
||||
{
|
||||
break parse_handshake_response(data)
|
||||
.map_err(ConnectError::Handshake)?;
|
||||
}
|
||||
return Err(ConnectError::Handshake(
|
||||
"Connection closed during handshake".to_string(),
|
||||
));
|
||||
}
|
||||
Some(_) => continue,
|
||||
None => sleep_ms(1).await,
|
||||
}
|
||||
};
|
||||
|
||||
// The relay assigns player_id == 0 exclusively to the host.
|
||||
let is_host = player_id == 0;
|
||||
|
||||
// 5. Set up channels between the UI and the background task.
|
||||
let (action_tx, action_rx) = mpsc::unbounded::<BackendMsg<A>>();
|
||||
let (event_tx, event_rx) = mpsc::unbounded::<SessionEvent<D, VS>>();
|
||||
|
||||
// 6. Spawn the background event loop.
|
||||
if is_host {
|
||||
spawn_task(host_loop::<A, D, VS, Backend>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
rule_variation,
|
||||
config.host_state,
|
||||
));
|
||||
} else {
|
||||
spawn_task(client_loop::<A, D, VS>(
|
||||
ws_sender,
|
||||
ws_receiver,
|
||||
action_rx,
|
||||
event_tx,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(GameSession {
|
||||
player_id,
|
||||
rule_variation,
|
||||
is_host,
|
||||
reconnect_token,
|
||||
action_tx,
|
||||
event_rx,
|
||||
})
|
||||
}
|
||||
|
||||
/// Sends a game action to the backend (fire-and-forget).
|
||||
pub fn send_action(&self, action: A) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Action(action))
|
||||
.ok();
|
||||
}
|
||||
|
||||
/// Awaits the next session event.
|
||||
///
|
||||
/// Returns `None` if the background task has exited (i.e. the session is
|
||||
/// over). Normal termination arrives as `Some(SessionEvent::Disconnected(_))`
|
||||
/// before the channel closes.
|
||||
pub async fn next_event(&mut self) -> Option<SessionEvent<D, VS>> {
|
||||
self.event_rx.next().await
|
||||
}
|
||||
|
||||
/// Signals the background task to send a graceful disconnect message and
|
||||
/// shut down. Consumes the session.
|
||||
pub fn disconnect(self) {
|
||||
self.action_tx
|
||||
.unbounded_send(BackendMsg::Disconnect)
|
||||
.ok();
|
||||
}
|
||||
}
|
||||
97
clients/backbone-lib/src/traits.rs
Normal file
97
clients/backbone-lib/src/traits.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// Marker trait for types that can be serialized with postcard.
|
||||
pub trait SerializationCap: Serialize + DeserializeOwned {}
|
||||
impl<T> SerializationCap for T where T: Serialize + DeserializeOwned {}
|
||||
|
||||
/// State updates delivered to the frontend for rendering.
|
||||
///
|
||||
/// - [`Full`](Self::Full): Immediately set all visual state, no animation.
|
||||
/// - [`Incremental`](Self::Incremental): Apply with animation/transition.
|
||||
pub enum ViewStateUpdate<ViewState, DeltaInformation> {
|
||||
/// Complete game state snapshot. Received on join or after a reset.
|
||||
Full(ViewState),
|
||||
/// Incremental state change for animated transitions.
|
||||
Incremental(DeltaInformation),
|
||||
}
|
||||
|
||||
/// Commands emitted by the game backend to control the session.
|
||||
pub enum BackendCommand<DeltaInformation>
|
||||
where
|
||||
DeltaInformation: SerializationCap,
|
||||
{
|
||||
/// Incremental state change to be broadcast to all frontends.
|
||||
Delta(DeltaInformation),
|
||||
|
||||
/// Signals a complete reset: discard queued deltas, broadcast fresh full state.
|
||||
ResetViewState,
|
||||
|
||||
/// Forcibly removes a player from the session.
|
||||
KickPlayer { player: u16 },
|
||||
|
||||
/// Schedules a callback after `duration` seconds. Overwrites any existing
|
||||
/// timer with the same `timer_id`.
|
||||
SetTimer { timer_id: u16, duration: f32 },
|
||||
|
||||
/// Cancels a previously scheduled timer. No-op if already fired or not set.
|
||||
CancelTimer { timer_id: u16 },
|
||||
|
||||
/// Shuts down the entire room and disconnects all players.
|
||||
TerminateRoom,
|
||||
}
|
||||
|
||||
/// The contract for game-specific server logic.
|
||||
///
|
||||
/// Implement this on the host side. The session calls these methods in response
|
||||
/// to network events and drives `drain_commands` to collect outbound messages.
|
||||
///
|
||||
/// # Type Parameters
|
||||
/// * `ServerRpcPayload` — Actions sent by players (e.g. `PlacePiece { x, y }`)
|
||||
/// * `DeltaInformation` — Incremental state changes for animations
|
||||
/// * `ViewState` — Complete game snapshot for syncing new clients
|
||||
pub trait BackEndArchitecture<ServerRpcPayload, DeltaInformation, ViewState>
|
||||
where
|
||||
ServerRpcPayload: SerializationCap,
|
||||
DeltaInformation: SerializationCap,
|
||||
ViewState: SerializationCap + Clone,
|
||||
{
|
||||
/// Creates a new game instance. `rule_variation` selects the game mode.
|
||||
fn new(rule_variation: u16) -> Self;
|
||||
|
||||
/// Attempt to restore a previously running game from serialized bytes.
|
||||
///
|
||||
/// Called when the host reconnects after a page refresh. The bytes are the
|
||||
/// game-specific snapshot produced by the app layer (via `serde_json` or
|
||||
/// similar) and stored in localStorage.
|
||||
///
|
||||
/// Return `None` if restoration is not supported or the bytes are invalid —
|
||||
/// the caller falls back to `new(rule_variation)`.
|
||||
fn from_bytes(_rule_variation: u16, _bytes: &[u8]) -> Option<Self>
|
||||
where
|
||||
Self: Sized,
|
||||
{
|
||||
None
|
||||
}
|
||||
|
||||
/// Called when a player connects. Player will receive a full state snapshot
|
||||
/// automatically after this returns.
|
||||
fn player_arrival(&mut self, player: u16);
|
||||
|
||||
/// Called when a player disconnects.
|
||||
fn player_departure(&mut self, player: u16);
|
||||
|
||||
/// Called when a player sends a game action.
|
||||
fn inform_rpc(&mut self, player: u16, payload: ServerRpcPayload);
|
||||
|
||||
/// Called when a previously scheduled timer fires.
|
||||
fn timer_triggered(&mut self, timer_id: u16);
|
||||
|
||||
/// Returns the complete current game state.
|
||||
fn get_view_state(&self) -> &ViewState;
|
||||
|
||||
/// Collects and clears all pending commands since the last drain.
|
||||
///
|
||||
/// Implement with `std::mem::take(&mut self.command_list)`.
|
||||
fn drain_commands(&mut self) -> Vec<BackendCommand<DeltaInformation>>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue