feat(web client): ask nickname when joining a room
This commit is contained in:
parent
d24f850882
commit
2c3281cc34
7 changed files with 268 additions and 50 deletions
|
|
@ -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::<RwSignal<Screen>>().expect("Screen context not found");
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||
.expect("UnboundedSender<NetCommand> not found in context");
|
||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context");
|
||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>().expect("NetCommand sender");
|
||||
let auth_username = use_context::<RwSignal<Option<String>>>().expect("auth_username context");
|
||||
let auth_loaded = use_context::<RwSignal<bool>>().expect("auth_loaded context");
|
||||
let anon_nickname = use_context::<RwSignal<Option<String>>>().expect("anon_nickname context");
|
||||
let query = use_query_map();
|
||||
|
||||
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||
// Non-None while the nickname-chooser modal is open.
|
||||
let pending_action: RwSignal<Option<PendingLobbyAction>> = 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! {
|
||||
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
||||
|
|
@ -114,54 +154,73 @@ pub fn LobbyPage() -> impl IntoView {
|
|||
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||
|
||||
{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! {
|
||||
<IdleCard on_create=on_create cmd_tx_bot=cmd_tx_bot />
|
||||
}.into_any()
|
||||
}
|
||||
LobbyView::Idle => view! {
|
||||
<IdleCard
|
||||
cmd_tx=cmd_idle.clone()
|
||||
auth_username=auth_username
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
/>
|
||||
}.into_any(),
|
||||
LobbyView::Waiting { code } => view! {
|
||||
<WaitingCard code=code />
|
||||
}.into_any(),
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
// Fixed-position modal overlay; rendered here but escapes layout.
|
||||
{move || pending_action.get().map(|action| view! {
|
||||
<NicknameModal
|
||||
pending=action
|
||||
cmd_tx=cmd_modal.clone()
|
||||
view_state=view_state
|
||||
pending_action=pending_action
|
||||
anon_nickname=anon_nickname
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<NetCommand>,
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
auth_username: RwSignal<Option<String>>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
) -> 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! {
|
||||
<div class="login-actions">
|
||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||
{t!(i18n, create_room)}
|
||||
</button>
|
||||
<button
|
||||
class="login-btn login-btn-bot"
|
||||
on:click=move |_| { cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||
on:click=move |_| { cmd_bot.unbounded_send(NetCommand::PlayVsBot).ok(); }
|
||||
>
|
||||
{t!(i18n, play_vs_bot)}
|
||||
</button>
|
||||
<button class="login-btn login-btn-primary" on:click=on_create>
|
||||
{t!(i18n, create_room)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
// Hidden "join by code" fallback
|
||||
|
|
@ -175,8 +234,7 @@ fn IdleCard(
|
|||
{t!(i18n, join_code_label)}
|
||||
</button>
|
||||
{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! {
|
||||
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
||||
<input
|
||||
|
|
@ -192,8 +250,12 @@ fn IdleCard(
|
|||
style="margin:0;padding:0 1rem"
|
||||
disabled=move || join_code.get().is_empty()
|
||||
on:click=move |_| {
|
||||
cmd.unbounded_send(NetCommand::JoinRoom { room: join_code.get() })
|
||||
.ok();
|
||||
let code = join_code.get();
|
||||
if auth_username.get_untracked().is_some() {
|
||||
cmd.unbounded_send(NetCommand::JoinRoom { room: code }).ok();
|
||||
} else {
|
||||
pending_action.set(Some(PendingLobbyAction::Join { code }));
|
||||
}
|
||||
}
|
||||
>
|
||||
{t!(i18n, join_room)}
|
||||
|
|
@ -205,7 +267,68 @@ fn IdleCard(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Waiting card: URL + copy + QR ────────────────────────────────────────────
|
||||
// ── NicknameModal ─────────────────────────────────────────────────────────────
|
||||
|
||||
#[component]
|
||||
fn NicknameModal(
|
||||
pending: PendingLobbyAction,
|
||||
cmd_tx: UnboundedSender<NetCommand>,
|
||||
view_state: RwSignal<LobbyView>,
|
||||
pending_action: RwSignal<Option<PendingLobbyAction>>,
|
||||
anon_nickname: RwSignal<Option<String>>,
|
||||
) -> 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! {
|
||||
<div class="nickname-backdrop">
|
||||
<div class="nickname-modal">
|
||||
<h2 class="nickname-modal-title">{t!(i18n, nickname_modal_title)}</h2>
|
||||
<p class="nickname-modal-hint">{t!(i18n, nickname_modal_hint)}</p>
|
||||
<input
|
||||
class="login-input"
|
||||
type="text"
|
||||
style="margin:0"
|
||||
prop:value=move || nick.get()
|
||||
on:input=move |ev| nick.set(event_target_value(&ev))
|
||||
/>
|
||||
<button
|
||||
class="login-btn login-btn-primary"
|
||||
disabled=move || nick.get().trim().is_empty()
|
||||
on:click=on_play
|
||||
>
|
||||
{t!(i18n, nickname_modal_play)}
|
||||
</button>
|
||||
<p class="nickname-modal-alt">
|
||||
{t!(i18n, nickname_modal_or)}
|
||||
" "
|
||||
<A href="/account">{t!(i18n, nickname_modal_sign_in)}</A>
|
||||
" · "
|
||||
<A href="/account">{t!(i18n, nickname_modal_register)}</A>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue