From 2c3281cc344d71410626e4635585ed58db6b38c7 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 4 May 2026 15:54:38 +0200 Subject: [PATCH 1/3] feat(web client): ask nickname when joining a room --- clients/web/assets/style.css | 60 +++++++ clients/web/locales/en.json | 8 +- clients/web/locales/fr.json | 8 +- clients/web/src/app.rs | 13 ++ clients/web/src/game/trictrac/backend.rs | 11 ++ clients/web/src/game/trictrac/types.rs | 2 + clients/web/src/portal/lobby.rs | 216 ++++++++++++++++++----- 7 files changed, 268 insertions(+), 50 deletions(-) diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index f241273..84af598 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1776,3 +1776,63 @@ a:hover { text-decoration: underline; } color: var(--ui-red-accent); font-style: italic; } + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 2d7ca43..5110ae6 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -121,5 +121,11 @@ "scan_qr": "or scan the QR code", "join_code_label": "Join by code", "join_code_placeholder": "Room code", - "share_btn": "Share" + "share_btn": "Share", + "nickname_modal_title": "Choose your nickname", + "nickname_modal_hint": "You will play as:", + "nickname_modal_play": "Play", + "nickname_modal_or": "or", + "nickname_modal_sign_in": "Sign in", + "nickname_modal_register": "Create account" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 3ff78aa..c1acee7 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -121,5 +121,11 @@ "scan_qr": "ou scannez le QR code", "join_code_label": "Rejoindre par code", "join_code_placeholder": "Code de salle", - "share_btn": "Partager" + "share_btn": "Partager", + "nickname_modal_title": "Choisissez votre pseudo", + "nickname_modal_hint": "Vous jouerez sous le nom de :", + "nickname_modal_play": "Jouer", + "nickname_modal_or": "ou", + "nickname_modal_sign_in": "Se connecter", + "nickname_modal_register": "Créer un compte" } diff --git a/clients/web/src/app.rs b/clients/web/src/app.rs index 5aa0f80..6383e3d 100644 --- a/clients/web/src/app.rs +++ b/clients/web/src/app.rs @@ -154,11 +154,19 @@ pub fn App() -> impl IntoView { let auth_email_verified: RwSignal = RwSignal::new(false); provide_context(auth_username); provide_context(auth_email_verified); + // Set to true once get_me resolves (success or failure) so lobby can + // decide immediately whether to show the nickname modal. + let auth_loaded: RwSignal = RwSignal::new(false); + provide_context(auth_loaded); + // Nickname chosen by an anonymous player; used instead of "Anonymous". + let anon_nickname: RwSignal> = RwSignal::new(None); + provide_context(anon_nickname); spawn_local(async move { if let Ok(me) = api::get_me().await { auth_username.set(Some(me.username)); auth_email_verified.set(me.email_verified); } + auth_loaded.set(true); }); let (cmd_tx, mut cmd_rx) = mpsc::unbounded::(); @@ -242,6 +250,7 @@ pub fn App() -> impl IntoView { if remote_config.is_none() { let player_name = auth_username .get_untracked() + .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| untrack(|| t_string!(i18n, anonymous_name).to_string())); loop { let restart = @@ -287,7 +296,11 @@ pub fn App() -> impl IntoView { let reconnect_token = session.reconnect_token; let my_name = auth_username .get_untracked() + .or_else(|| anon_nickname.get_untracked()) .unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string()); + // Announce our name to the host backend so it can broadcast it to + // the opponent. Done once immediately after connecting. + session.send_action(PlayerAction::SetName(my_name.clone())); let mut vs = ViewState::default_with_names("", ""); let mut result_submitted = false; diff --git a/clients/web/src/game/trictrac/backend.rs b/clients/web/src/game/trictrac/backend.rs index 3581057..67a2970 100644 --- a/clients/web/src/game/trictrac/backend.rs +++ b/clients/web/src/game/trictrac/backend.rs @@ -202,6 +202,16 @@ impl BackEndArchitecture for TrictracBackend } fn inform_rpc(&mut self, mp_player: u16, action: PlayerAction) { + // SetName is always accepted regardless of game stage or whose turn it is. + if let PlayerAction::SetName(name) = action { + let store_id = if mp_player == 0 { HOST_PLAYER_ID } else { GUEST_PLAYER_ID }; + if let Some(p) = self.game.players.get_mut(&store_id) { + p.name = name; + } + self.broadcast_state(); + return; + } + // During the first-player ceremony only PreGameRoll actions are accepted. if self.ceremony_started { if matches!(action, PlayerAction::PreGameRoll) { @@ -262,6 +272,7 @@ impl BackEndArchitecture for TrictracBackend } } PlayerAction::PreGameRoll => {} // ignored outside ceremony + PlayerAction::SetName(_) => {} // handled at the top of inform_rpc } self.broadcast_state(); diff --git a/clients/web/src/game/trictrac/types.rs b/clients/web/src/game/trictrac/types.rs index 3c0dfe2..45ac48c 100644 --- a/clients/web/src/game/trictrac/types.rs +++ b/clients/web/src/game/trictrac/types.rs @@ -16,6 +16,8 @@ pub enum PlayerAction { Mark, /// Roll a single die during the pre-game ceremony to decide who goes first. PreGameRoll, + /// Declare the player's display name; sent once immediately after connecting. + SetName(String), } // ── Incremental state update broadcast to all clients ──────────────────────── diff --git a/clients/web/src/portal/lobby.rs b/clients/web/src/portal/lobby.rs index 7a1d71e..9902039 100644 --- a/clients/web/src/portal/lobby.rs +++ b/clients/web/src/portal/lobby.rs @@ -1,11 +1,12 @@ use futures::channel::mpsc::UnboundedSender; use leptos::prelude::*; +use leptos_router::components::A; use leptos_router::hooks::use_query_map; use crate::app::{NetCommand, Screen}; use crate::i18n::*; -// ── Room code generation ────────────────────────────────────────────────────── +// ── Room/nickname generation ────────────────────────────────────────────────── fn generate_room_code() -> String { use rand::Rng; @@ -16,6 +17,23 @@ fn generate_room_code() -> String { .collect() } +fn generate_nickname() -> String { + use rand::Rng; + let mut rng = rand::rng(); + const ADJ: &[&str] = &[ + "swift", "brave", "noble", "fierce", "clever", "bold", "cunning", + "agile", "sharp", "golden", "iron", "silver", "quick", "daring", "wild", + ]; + const NOUN: &[&str] = &[ + "fox", "hawk", "wolf", "lion", "bear", "rook", "knight", + "duke", "earl", "lance", "blade", "crown", "dame", "ace", "star", + ]; + let adj = ADJ[rng.random_range(0..ADJ.len())]; + let noun = NOUN[rng.random_range(0..NOUN.len())]; + let num: u8 = rng.random_range(10..=99); + format!("{adj}-{noun}-{num}") +} + // ── QR code SVG rendering ───────────────────────────────────────────────────── pub(crate) fn qr_svg(text: &str) -> String { @@ -62,7 +80,14 @@ pub(crate) fn room_url(code: &str) -> String { format!("http://localhost:9091/?room={}", code) } -// ── Lobby component ─────────────────────────────────────────────────────────── +// ── Lobby state ─────────────────────────────────────────────────────────────── + +/// Action to execute once the anonymous player has chosen their nickname. +#[derive(Clone)] +enum PendingLobbyAction { + Create { code: String }, + Join { code: String }, +} #[derive(Clone)] enum LobbyView { @@ -70,24 +95,38 @@ enum LobbyView { Waiting { code: String }, } +// ── LobbyPage ───────────────────────────────────────────────────────────────── + #[component] pub fn LobbyPage() -> impl IntoView { - let screen = use_context::>().expect("Screen context not found"); - let cmd_tx = use_context::>() - .expect("UnboundedSender not found in context"); + let screen = use_context::>().expect("Screen context"); + let cmd_tx = use_context::>().expect("NetCommand sender"); + let auth_username = use_context::>>().expect("auth_username context"); + let auth_loaded = use_context::>().expect("auth_loaded context"); + let anon_nickname = use_context::>>().expect("anon_nickname context"); let query = use_query_map(); let view_state: RwSignal = RwSignal::new(LobbyView::Idle); + // Non-None while the nickname-chooser modal is open. + let pending_action: RwSignal> = RwSignal::new(None); - // Auto-join when the URL contains ?room=CODE - let cmd_tx_query = cmd_tx.clone(); + // ── Auto-join when URL has ?room=CODE ────────────────────────────────── + // Wait for auth to resolve so we join directly when already logged in, + // or show the nickname modal when anonymous. + let join_processed = StoredValue::new(false); + let cmd_tx_q = cmd_tx.clone(); Effect::new(move |_| { - if let Some(code) = query.read().get("room") { - if !code.is_empty() { - cmd_tx_query - .unbounded_send(NetCommand::JoinRoom { room: code }) - .ok(); - } + if join_processed.get_value() || !auth_loaded.get() { + return; + } + let Some(code) = query.read().get("room").filter(|s| !s.is_empty()) else { + return; + }; + join_processed.set_value(true); + if auth_username.get_untracked().is_some() { + cmd_tx_q.unbounded_send(NetCommand::JoinRoom { room: code }).ok(); + } else { + pending_action.set(Some(PendingLobbyAction::Join { code })); } }); @@ -96,7 +135,8 @@ pub fn LobbyPage() -> impl IntoView { _ => None, }; - let cmd_tx_idle = cmd_tx; + let cmd_idle = cmd_tx.clone(); + let cmd_modal = cmd_tx; view! {
@@ -114,54 +154,73 @@ pub fn LobbyPage() -> impl IntoView { {move || error().map(|err| view! {

{err}

})} {move || match view_state.get() { - LobbyView::Idle => { - // Create fresh closures each render so they are FnMut-compatible - let cmd_tx_create = cmd_tx_idle.clone(); - let cmd_tx_bot = cmd_tx_idle.clone(); - let on_create = move |_: leptos::ev::MouseEvent| { - let code = generate_room_code(); - cmd_tx_create - .unbounded_send(NetCommand::CreateRoom { room: code.clone() }) - .ok(); - view_state.set(LobbyView::Waiting { code }); - }; - view! { - - }.into_any() - } + LobbyView::Idle => view! { + + }.into_any(), LobbyView::Waiting { code } => view! { }.into_any(), }}
+ + // Fixed-position modal overlay; rendered here but escapes layout. + {move || pending_action.get().map(|action| view! { + + })} } } -// ── Idle card: Create + vs Bot + hidden join-by-code ───────────────────────── +// ── IdleCard: Create + vs Bot + hidden join-by-code ────────────────────────── #[component] fn IdleCard( - on_create: impl Fn(leptos::ev::MouseEvent) + 'static, - cmd_tx_bot: UnboundedSender, + cmd_tx: UnboundedSender, + auth_username: RwSignal>, + view_state: RwSignal, + pending_action: RwSignal>, ) -> impl IntoView { let i18n = use_i18n(); let join_open = RwSignal::new(false); let join_code = RwSignal::new(String::new()); - let cmd_tx_join = cmd_tx_bot.clone(); + + let cmd_bot = cmd_tx.clone(); + let cmd_create = cmd_tx.clone(); + let cmd_join = cmd_tx; + + let on_create = move |_: leptos::ev::MouseEvent| { + let code = generate_room_code(); + if auth_username.get_untracked().is_some() { + cmd_create.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok(); + view_state.set(LobbyView::Waiting { code }); + } else { + pending_action.set(Some(PendingLobbyAction::Create { code })); + } + }; view! { // Hidden "join by code" fallback @@ -175,8 +234,7 @@ fn IdleCard( {t!(i18n, join_code_label)} {move || { - // Clone the sender on each reactive run to keep the outer closure FnMut - let cmd = cmd_tx_join.clone(); + let cmd = cmd_join.clone(); join_open.get().then(|| view! {
{t!(i18n, join_room)} @@ -205,7 +267,68 @@ fn IdleCard( } } -// ── Waiting card: URL + copy + QR ──────────────────────────────────────────── +// ── NicknameModal ───────────────────────────────────────────────────────────── + +#[component] +fn NicknameModal( + pending: PendingLobbyAction, + cmd_tx: UnboundedSender, + view_state: RwSignal, + pending_action: RwSignal>, + anon_nickname: RwSignal>, +) -> impl IntoView { + let i18n = use_i18n(); + // Pre-fill with a random nickname; the player can edit it. + let nick = RwSignal::new(generate_nickname()); + + let on_play = move |_: leptos::ev::MouseEvent| { + let chosen = nick.get().trim().to_string(); + let chosen = if chosen.is_empty() { generate_nickname() } else { chosen }; + anon_nickname.set(Some(chosen)); + match &pending { + PendingLobbyAction::Create { code } => { + cmd_tx.unbounded_send(NetCommand::CreateRoom { room: code.clone() }).ok(); + view_state.set(LobbyView::Waiting { code: code.clone() }); + } + PendingLobbyAction::Join { code } => { + cmd_tx.unbounded_send(NetCommand::JoinRoom { room: code.clone() }).ok(); + } + } + pending_action.set(None); + }; + + view! { +
+
+

{t!(i18n, nickname_modal_title)}

+

{t!(i18n, nickname_modal_hint)}

+ + +

+ {t!(i18n, nickname_modal_or)} + " " + {t!(i18n, nickname_modal_sign_in)} + " · " + {t!(i18n, nickname_modal_register)} +

+
+
+ } +} + +// ── WaitingCard: URL + copy + QR ───────────────────────────────────────────── #[component] fn WaitingCard(code: String) -> impl IntoView { @@ -221,12 +344,9 @@ fn WaitingCard(code: String) -> impl IntoView { { let url = url.clone(); wasm_bindgen_futures::spawn_local(async move { - if let Some(clipboard) = web_sys::window() - .map(|w| w.navigator().clipboard()) - { - let _ = wasm_bindgen_futures::JsFuture::from( - clipboard.write_text(&url) - ).await; + if let Some(clipboard) = web_sys::window().map(|w| w.navigator().clipboard()) { + let _ = + wasm_bindgen_futures::JsFuture::from(clipboard.write_text(&url)).await; copied.set(true); gloo_timers::future::TimeoutFuture::new(2000).await; copied.set(false); From e0698986f1728014640424391b77cec07da98e81 Mon Sep 17 00:00:00 2001 From: Henri Bourcereau Date: Mon, 4 May 2026 16:22:24 +0200 Subject: [PATCH 2/3] fix(web client): show invitation link until opponent connects --- .../web/src/game/components/game_screen.rs | 55 +++++++++++++++---- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 2a1d761..011f962 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -246,7 +246,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let opp_name_end = opp_score.name.clone(); let opp_holes_end = opp_score.holes; - let share_open = RwSignal::new(false); + // Open by default for the room creator while waiting for an opponent. + // When the opponent joins the stage becomes PreGameRoll, so the next + // re-mount will initialise this to false — auto-closing the popover. + let share_open = RwSignal::new(!is_bot_game && player_id == 0 && stage == SerStage::PreGame); + let share_copied = RwSignal::new(false); let share_url = if !is_bot_game { room_url(&room_id) } else { @@ -290,17 +294,46 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// ── Share popover ───────────────────────────────────────────────── - {move || share_open.get().then(|| view! { -