client lobby refactor

This commit is contained in:
emilis 2025-10-02 19:45:25 +01:00
parent 01c1a4554a
commit c96e019071
No known key found for this signature in database
8 changed files with 502 additions and 60 deletions

View File

@ -737,3 +737,20 @@ input {
.zoom { .zoom {
zoom: 200%; zoom: 200%;
} }
.client-lobby-player-list {
@extend .row-list;
gap: 10px;
&>* {
border: 1px solid white;
padding: 10px;
text-align: center;
&:hover {
background-color: #fff;
color: #000;
}
}
}

View File

@ -1,5 +1,5 @@
use core::{num::NonZeroU8, sync::atomic::AtomicBool, time::Duration}; use core::{num::NonZeroU8, sync::atomic::AtomicBool, time::Duration};
use std::collections::VecDeque; use std::{collections::VecDeque, rc::Rc};
use futures::{ use futures::{
SinkExt, StreamExt, SinkExt, StreamExt,
@ -21,12 +21,13 @@ use werewolves_proto::{
player::PlayerId, player::PlayerId,
role::RoleTitle, role::RoleTitle,
}; };
use yew::{html::Scope, prelude::*}; use yew::{html::Scope, prelude::*, suspense::use_future};
use crate::{ use crate::{
clients::client::connection::{Connection2, ConnectionError},
components::{ components::{
Button, Identity, Notification, Button, Identity, Notification,
client::{ClientNav, InputName}, client::{ClientNav, Signin},
}, },
storage::StorageKey, storage::StorageKey,
}; };
@ -184,7 +185,7 @@ impl Connection {
} }
} }
#[derive(PartialEq)] #[derive(PartialEq, Debug, Clone)]
pub enum ClientEvent { pub enum ClientEvent {
Disconnected, Disconnected,
Waiting, Waiting,
@ -219,6 +220,168 @@ impl TryFrom<ServerMessage> for ClientEvent {
} }
} }
#[derive(PartialEq, Debug, Clone)]
pub enum ClientEvent2 {
Disconnected,
Connecting,
Waiting,
ShowRole(RoleTitle),
Lobby {
joined: bool,
players: Rc<[PublicIdentity]>,
},
}
#[derive(Default, Clone, PartialEq)]
pub struct ClientContext {
pub error_cb: Callback<Option<WerewolfError>>,
pub forced_identity: Option<Identification>,
}
#[function_component]
pub fn Client2(ClientProps { auto_join }: &ClientProps) -> Html {
let client_state = use_state(|| ClientEvent2::Connecting);
let ClientContext {
error_cb,
forced_identity,
} = use_context::<ClientContext>().unwrap_or_default();
let force = use_force_update();
let ident = if let Some(Identification { player_id, public }) = forced_identity {
(player_id, public)
} else {
match PlayerId::load_from_storage()
.and_then(|pid| PublicIdentity::load_from_storage().map(|ident| (pid, ident)))
{
Ok((pid, ident)) => (pid, ident),
Err(StorageError::KeyNotFound(_)) => {
let on_signin = Callback::from(move |ident: PublicIdentity| {
PlayerId::new().save_to_storage().expect("saving player id");
ident.save_to_storage().expect("saving ident");
force.force_update();
});
return html! {
<Signin callback={on_signin}/>
};
}
Err(err) => {
error_cb.emit(Some(err.into()));
PlayerId::delete();
PublicIdentity::delete();
force.force_update();
return html! {};
}
}
};
let ident = use_state(|| ident);
let (send, recv) = yew::platform::pinned::mpsc::unbounded();
let send = use_state(|| send);
let recv = use_mut_ref(|| recv);
let connection = use_mut_ref(|| Connection2::new(client_state.setter(), ident.clone(), recv));
let content = match &*client_state {
ClientEvent2::Disconnected => html! {<p>{"disconnected"}</p>},
ClientEvent2::Connecting => {
if let Err(err) = connection
.try_borrow_mut()
.map_err(|_| ConnectionError::ConnectionAlreadyActive)
.and_then(|mut conn| conn.start())
{
error_cb.emit(Some(err.into()));
}
if *auto_join {
let _ = send.send_now(ClientMessage::Hello);
}
html! {<p>{"connecting..."}</p>}
}
ClientEvent2::Waiting => html! {<p>{"waiting..."}</p>},
ClientEvent2::ShowRole(role_title) => {
let send = (*send).clone();
let error_cb = error_cb.clone();
let on_click = Callback::from(move |_| {
if let Err(err) = send.clone().send_now(ClientMessage::RoleAck) {
error_cb.emit(Some(err.into()));
}
});
html! {
<div class="game-start-role">
<p>{format!("Your role: {role_title}")}</p>
<button onclick={on_click}>{"got it"}</button>
</div>
}
}
ClientEvent2::Lobby { joined, players } => {
let player = {
html! {
<Identity ident={ident.1.clone()} class="zoom"/>
}
};
let player_list = players
.iter()
.map(|ident| {
html! {
<Identity ident={ident.clone()}/>
}
})
.collect::<Html>();
let button_send = (*send).clone();
let err_cb = error_cb.clone();
let button = if *joined {
let cb = move |_| {
if let Err(err) = button_send.send_now(ClientMessage::Goodbye) {
err_cb.emit(Some(err.into()));
}
};
html! {
<Button on_click={cb}>{"leave"}</Button>
}
} else {
let cb = move |_| {
if let Err(err) = button_send.send_now(ClientMessage::Hello) {
err_cb.emit(Some(err.into()));
}
};
html! {
<Button on_click={cb}>{"join"}</Button>
}
};
html! {
<div class="column-list gap">
{player}
<h2>{"there are currently "}{players.len()}{" players in the lobby"}</h2>
{button}
<div class="client-lobby-player-list">
{player_list}
</div>
</div>
}
}
};
let nav = {
let send = (*send).clone();
let error_cb = error_cb.clone();
let client_nav_msg_cb = move |msg| {
if let Err(err) = send.send_now(msg) {
error_cb.emit(Some(err.into()))
}
};
html! {
<ClientNav message_callback={client_nav_msg_cb} />
}
};
html! {
<>
{nav}
{content}
</>
}
}
pub struct Client { pub struct Client {
player: Option<Identification>, player: Option<Identification>,
send: Sender<ClientMessage>, send: Sender<ClientMessage>,
@ -284,7 +447,7 @@ impl Component for Client {
scope.send_message(Message::SetPublicIdentity(public)); scope.send_message(Message::SetPublicIdentity(public));
}); });
return html! { return html! {
<InputName callback={callback}/> <Signin callback={callback}/>
}; };
} else if self.recv.is_some() { } else if self.recv.is_some() {
// Player info loaded, but connection isn't started // Player info loaded, but connection isn't started

View File

@ -0,0 +1,252 @@
use core::num::NonZeroU8;
use core::time::Duration;
use std::cell::RefCell;
use std::rc::Rc;
use futures::{SinkExt, StreamExt};
use gloo::net::websocket::{self, futures::WebSocket};
use instant::Instant;
use serde::Serialize;
use thiserror::Error;
use werewolves_proto::message::{PlayerUpdate, ServerMessage};
use werewolves_proto::{
message::{ClientMessage, Identification, PublicIdentity},
player::PlayerId,
};
use yew::{platform::pinned::mpsc::UnboundedReceiver, prelude::*};
use crate::clients::client::ClientEvent2;
#[derive(Debug, Clone, PartialEq, Error)]
pub enum ConnectionError {
#[error("connection already active")]
ConnectionAlreadyActive,
}
fn url() -> String {
format!(
"{}client",
option_env!("LOCAL")
.map(|_| crate::clients::DEBUG_URL)
.unwrap_or(crate::clients::LIVE_URL)
)
}
#[derive(Clone)]
pub struct Connection2 {
state: UseStateSetter<ClientEvent2>,
ident: UseStateHandle<(PlayerId, PublicIdentity)>,
receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
active: Rc<RefCell<()>>,
}
impl Connection2 {
pub fn new(
state: UseStateSetter<ClientEvent2>,
ident: UseStateHandle<(PlayerId, PublicIdentity)>,
receiver: Rc<RefCell<UnboundedReceiver<ClientMessage>>>,
) -> Self {
Self {
state,
ident,
receiver,
active: Rc::new(RefCell::new(())),
}
}
fn identification(&self) -> Identification {
Identification {
player_id: self.ident.0.clone(),
public: self.ident.1.clone(),
}
}
async fn connect_ws() -> WebSocket {
let url = url();
loop {
match WebSocket::open(&url) {
Ok(ws) => break ws,
Err(err) => {
log::error!("connect: {err}");
yew::platform::time::sleep(Duration::from_secs(1)).await;
}
}
}
}
fn encode_message(msg: &impl Serialize) -> websocket::Message {
#[cfg(feature = "json")]
{
websocket::Message::Text(serde_json::to_string(msg).expect("message serialization"))
}
#[cfg(feature = "cbor")]
{
websocket::Message::Bytes({
let mut v = Vec::new();
ciborium::into_writer(msg, &mut v).expect("serializing message");
v
})
}
}
pub fn start(&mut self) -> Result<(), ConnectionError> {
let active = self
.active
.try_borrow_mut()
.map_err(|_| ConnectionError::ConnectionAlreadyActive)?;
core::mem::drop(active);
let mut conn = self.clone();
yew::platform::spawn_local(async move {
let active = conn.active.clone();
conn.active = Rc::new(RefCell::new(()));
let active_borrow = active.borrow_mut();
conn.run().await;
core::mem::drop(active_borrow);
});
Ok(())
}
async fn run(&mut self) {
const CONNECT_WAIT: Duration = Duration::from_secs(3);
let url = url();
let mut last_connect: Option<Instant> = None;
'outer: loop {
if let Some(last_connect) = last_connect.as_ref() {
let time_since_last = Instant::now() - *last_connect;
if time_since_last <= CONNECT_WAIT {
yew::platform::time::sleep(CONNECT_WAIT.saturating_sub(time_since_last)).await;
continue;
}
}
last_connect = Some(Instant::now());
log::info!("connecting to {url}");
let mut ws = Self::connect_ws().await.fuse();
log::info!("connected to {url}");
log::debug!("sending self ident");
if let Err(err) = ws.send(Self::encode_message(&self.identification())).await {
log::error!("websocket identification send: {err}");
continue 'outer;
};
log::debug!("sending get state");
if let Err(err) = ws
.send(Self::encode_message(&ClientMessage::GetState))
.await
{
log::error!("websocket identification send: {err}");
continue 'outer;
};
log::debug!("beginning listening loop");
loop {
let mut recv = self.receiver.borrow_mut();
let msg = futures::select! {
r = ws.next() => {
match r {
Some(Ok(msg)) => msg,
Some(Err(err)) => {
log::error!("websocket recv: {err}");
continue 'outer;
},
None => {
log::warn!("websocket closed");
continue 'outer;
},
}
}
r = recv.next() => {
match r {
Some(msg) => {
log::info!("sending message: {msg:?}");
if let Err(err) = ws.send(
Self::encode_message(&msg)
).await {
log::error!("websocket send error: {err}");
continue 'outer;
}
continue;
},
None => {
log::info!("recv channel closed");
return;
},
}
},
};
core::mem::drop(recv);
let parse = {
#[cfg(feature = "json")]
{
match msg {
websocket::Message::Text(text) => {
serde_json::from_str::<ServerMessage>(&text)
}
websocket::Message::Bytes(items) => serde_json::from_slice(&items),
}
}
#[cfg(feature = "cbor")]
{
match msg {
websocket::Message::Text(_) => {
log::error!("text messages not supported in cbor mode; discarding");
continue;
}
websocket::Message::Bytes(bytes) => {
ciborium::from_reader::<ServerMessage, _>(bytes.as_slice())
}
}
}
};
match parse {
Ok(msg) => {
if let Some(state) = self.message_to_client_state(msg) {
self.state.set(state);
}
}
Err(err) => {
log::error!("parsing server message: {err}; ignoring.")
}
}
}
}
}
fn message_to_client_state(&self, msg: ServerMessage) -> Option<ClientEvent2> {
log::debug!("received message: {msg:?}");
Some(match msg {
ServerMessage::Disconnect => ClientEvent2::Disconnected,
ServerMessage::LobbyInfo {
joined,
mut players,
} => {
const LAST: NonZeroU8 = NonZeroU8::new(0xFF).unwrap();
players.sort_by(|l, r| l.number.unwrap_or(LAST).cmp(&r.number.unwrap_or(LAST)));
ClientEvent2::Lobby {
joined,
players: players.into_iter().collect(),
}
}
ServerMessage::GameStart { role } => ClientEvent2::ShowRole(role),
ServerMessage::InvalidMessageForGameState => {
log::error!("invalid message for game state");
return None;
}
ServerMessage::NoSuchTarget => {
log::error!("no such target");
return None;
}
ServerMessage::Update(PlayerUpdate::Number(new_num)) => {
let (pid, mut ident) = (*self.ident).clone();
ident.number = Some(new_num);
self.ident.set((pid, ident));
return None;
}
ServerMessage::GameOver(_)
| ServerMessage::Sleep
| ServerMessage::Reset
| ServerMessage::GameInProgress => {
return None;
}
})
}
}

View File

@ -1,5 +1,6 @@
pub mod client { pub mod client {
mod client; mod client;
pub mod connection;
pub use client::*; pub use client::*;
} }
pub mod host { pub mod host {

View File

@ -7,12 +7,12 @@ use yew::prelude::*;
use crate::components::Button; use crate::components::Button;
#[derive(Debug, PartialEq, Properties)] #[derive(Debug, PartialEq, Properties)]
pub struct InputProps { pub struct SigninProps {
pub callback: Callback<PublicIdentity, ()>, pub callback: Callback<PublicIdentity>,
} }
#[function_component] #[function_component]
pub fn InputName(props: &InputProps) -> Html { pub fn Signin(props: &SigninProps) -> Html {
let callback = props.callback.clone(); let callback = props.callback.clone();
let num_value = use_state(String::new); let num_value = use_state(String::new);
let name_value = use_state(String::new); let name_value = use_state(String::new);
@ -44,27 +44,7 @@ pub fn InputName(props: &InputProps) -> Html {
number, number,
}); });
}); });
// let num_value = num_value.clone();
// let on_change = move |ev: InputEvent| {
// let data = ev.data();
// if let Some(z) = data.as_ref().and_then(|d| d.trim().parse::<u8>().ok())
// && !(z == 0 && num_value.is_empty())
// {
// let new_value = format!("{}{z}", num_value.as_str());
// num_value.set(new_value);
// return;
// } else if data.is_none()
// && let Some(target) = ev.target_dyn_into::<HtmlInputElement>()
// {
// num_value.set(target.value());
// return;
// }
// if let Some(target) = ev.target_dyn_into::<HtmlInputElement>() {
// target.set_value(num_value.as_str());
// }
// };
let on_change = crate::components::input_element_number_oninput(num_value); let on_change = crate::components::input_element_number_oninput(num_value);
html! { html! {
<div class="signin"> <div class="signin">

View File

@ -37,17 +37,3 @@ pub fn Identity(props: &IdentityProps) -> Html {
</div> </div>
} }
} }
#[derive(Debug, Clone, PartialEq, Properties)]
pub struct StatefulIdentityProps {
pub ident: UseStateHandle<PublicIdentity>,
#[prop_or_default]
pub class: Option<String>,
}
#[function_component]
pub fn StatefulIdentity(props: &StatefulIdentityProps) -> Html {
html! {
<Identity ident={props.ident.deref().clone()} class={props.class.clone()}/>
}
}

View File

@ -24,10 +24,10 @@ use werewolves_proto::{
message::{Identification, PublicIdentity}, message::{Identification, PublicIdentity},
player::PlayerId, player::PlayerId,
}; };
use yew::prelude::*; use yew::{context::ContextProviderProps, prelude::*};
use crate::clients::{ use crate::clients::{
client::{Client, ClientProps, Message}, client::{Client, Client2, ClientContext, ClientProps, Message},
host::{Host, HostEvent}, host::{Host, HostEvent},
}; };
@ -70,25 +70,62 @@ fn main() {
clients.append_child(&dupe).unwrap(); clients.append_child(&dupe).unwrap();
} }
let client = let client = yew::Renderer::<ContextProvider<ClientContext>>::with_root_and_props(
yew::Renderer::<Client>::with_root_and_props(dupe, ClientProps { auto_join: true }) dupe,
.render(); ContextProviderProps {
client.send_message(Message::ForceIdentity(Identification { context: ClientContext {
error_cb: error_callback.clone(),
forced_identity: Some(Identification {
player_id, player_id,
public: PublicIdentity { public: PublicIdentity {
name: name.to_string(), name: name.to_string(),
pronouns: Some(String::from("he/him")), pronouns: Some(String::from("he/him")),
number: None, number: None,
}, },
})); }),
client.send_message(Message::SetErrorCallback(error_callback.clone())); },
} children: html! {
} else { <Client2 auto_join=true/>
let client = yew::Renderer::<Client>::with_root_and_props( },
app_element, },
ClientProps { auto_join: false },
) )
.render(); .render();
client.send_message(Message::SetErrorCallback(error_callback));
// let client = yew::Renderer::<Client2>::with_root_and_props(
// dupe,
// ClientProps { auto_join: true },
// )
// .render();
// client.send_message(Message::ForceIdentity(Identification {
// player_id,
// public: PublicIdentity {
// name: name.to_string(),
// pronouns: Some(String::from("he/him")),
// number: None,
// },
// }));
// client.send_message(Message::SetErrorCallback(error_callback.clone()));
}
} else {
let client = yew::Renderer::<ContextProvider<ClientContext>>::with_root_and_props(
app_element,
ContextProviderProps {
context: ClientContext {
error_cb: error_callback.clone(),
forced_identity: None,
},
children: html! {
<Client2 auto_join=false/>
},
},
)
.render();
// let client = yew::Renderer::<Client>::with_root_and_props(
// app_element,
// ClientProps { auto_join: false },
// )
// .render();
// client.send_message(Message::SetErrorCallback(error_callback));
} }
} }

View File

@ -1,9 +1,11 @@
use gloo::storage::errors::StorageError; use gloo::storage::errors::StorageError;
use thiserror::Error; use thiserror::Error;
use werewolves_proto::error::GameError; use werewolves_proto::{error::GameError, message::ClientMessage};
use yew::prelude::*; use yew::prelude::*;
#[derive(Debug, Clone, PartialEq, Error)] use crate::clients::client::connection::ConnectionError;
#[derive(Debug, Error)]
pub enum WerewolfError { pub enum WerewolfError {
#[error("{0}")] #[error("{0}")]
GameError(#[from] GameError), GameError(#[from] GameError),
@ -13,6 +15,10 @@ pub enum WerewolfError {
InvalidTarget, InvalidTarget,
#[error("send error: {0}")] #[error("send error: {0}")]
SendError(#[from] futures::channel::mpsc::SendError), SendError(#[from] futures::channel::mpsc::SendError),
#[error("send error: {0}")]
ClientSendError(#[from] yew::platform::pinned::mpsc::SendError<ClientMessage>),
#[error("connection error: {0}")]
ConnectionError(#[from] ConnectionError),
} }
impl From<StorageError> for WerewolfError { impl From<StorageError> for WerewolfError {