Compare commits
2 commits
15a2963f7e
...
04369ea28e
| Author | SHA1 | Date | |
|---|---|---|---|
| 04369ea28e | |||
| c46d26ae02 |
11 changed files with 431 additions and 100 deletions
7
Cargo.lock
generated
7
Cargo.lock
generated
|
|
@ -6347,6 +6347,12 @@ dependencies = [
|
||||||
"bytemuck",
|
"bytemuck",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "qrcodegen"
|
||||||
|
version = "1.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4339fc7a1021c9c1621d87f5e3505f2805c8c105420ba2f2a4df86814590c142"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-error"
|
name = "quick-error"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
|
|
@ -8714,6 +8720,7 @@ dependencies = [
|
||||||
"leptos",
|
"leptos",
|
||||||
"leptos_i18n",
|
"leptos_i18n",
|
||||||
"leptos_router",
|
"leptos_router",
|
||||||
|
"qrcodegen",
|
||||||
"rand 0.9.3",
|
"rand 0.9.3",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ serde_json = "1"
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
rand = "0.9"
|
rand = "0.9"
|
||||||
gloo-storage = "0.3"
|
gloo-storage = "0.3"
|
||||||
|
qrcodegen = "1.8"
|
||||||
|
|
||||||
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
[target.'cfg(target_arch = "wasm32")'.dependencies]
|
||||||
wasm-bindgen = "0.2"
|
wasm-bindgen = "0.2"
|
||||||
|
|
@ -38,4 +39,7 @@ web-sys = { version = "0.3", features = [
|
||||||
"OscillatorType",
|
"OscillatorType",
|
||||||
"BaseAudioContext",
|
"BaseAudioContext",
|
||||||
"HtmlAudioElement",
|
"HtmlAudioElement",
|
||||||
|
"Clipboard",
|
||||||
|
"Navigator",
|
||||||
|
"Location",
|
||||||
] }
|
] }
|
||||||
|
|
|
||||||
|
|
@ -287,10 +287,74 @@ a:hover { text-decoration: underline; }
|
||||||
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
.portal-error { color: var(--ui-red-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||||
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
.portal-success { color: var(--ui-green-accent); font-size: 0.875rem; margin-top: 0.5rem; }
|
||||||
|
|
||||||
|
/* ── Share URL row (lobby waiting card + game top bar) ──────────── */
|
||||||
|
.share-url-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
background: rgba(0,0,0,0.18);
|
||||||
|
border: 1px solid rgba(200,164,72,0.25);
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 0.4rem 0.6rem;
|
||||||
|
}
|
||||||
|
.share-url-text {
|
||||||
|
flex: 1;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: rgba(242,232,208,0.75);
|
||||||
|
word-break: break-all;
|
||||||
|
user-select: all;
|
||||||
|
}
|
||||||
|
.share-copy-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-family: var(--font-ui);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
padding: 0.2rem 0.6rem;
|
||||||
|
border: 1px solid rgba(200,164,72,0.4);
|
||||||
|
border-radius: 3px;
|
||||||
|
background: rgba(200,164,72,0.1);
|
||||||
|
color: var(--ui-parchment);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.share-copy-btn:hover { background: rgba(200,164,72,0.22); }
|
||||||
|
|
||||||
|
/* ── QR code container ───────────────────────────────────────────── */
|
||||||
|
.qr-container {
|
||||||
|
width: 160px;
|
||||||
|
height: 160px;
|
||||||
|
margin: 0 auto;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.qr-container svg { width: 100%; height: 100%; display: block; }
|
||||||
|
|
||||||
|
/* ── Share popover (in-game top bar) ─────────────────────────────── */
|
||||||
|
.share-popover {
|
||||||
|
width: 100%;
|
||||||
|
background: rgba(0,0,0,0.3);
|
||||||
|
border: 1px solid rgba(200,164,72,0.2);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
.share-popover .qr-container { width: 120px; height: 120px; }
|
||||||
|
.share-popover-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(242,232,208,0.6);
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Game overlay (full-screen, covers portal during play) ───────── */
|
/* ── Game overlay (full-screen, covers portal during play) ───────── */
|
||||||
.game-overlay {
|
.game-overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 54px 0 0 0;
|
||||||
background: #8a7050;
|
background: #8a7050;
|
||||||
background-image:
|
background-image:
|
||||||
radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
|
radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%),
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"hint_move": "Click a highlighted field to move a checker",
|
"hint_move": "Click a highlighted field to move a checker",
|
||||||
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
|
"hint_hold_or_go": "Hold to keep points — Go to reset the setting",
|
||||||
"hint_continue": "Click Continue when ready",
|
"hint_continue": "Click Continue when ready",
|
||||||
|
"anonymous_name": "Anonymous",
|
||||||
"login_failed": "Invalid username or password.",
|
"login_failed": "Invalid username or password.",
|
||||||
"sign_in": "Sign in",
|
"sign_in": "Sign in",
|
||||||
"sign_out": "Sign out",
|
"sign_out": "Sign out",
|
||||||
|
|
@ -93,5 +94,12 @@
|
||||||
"anonymous_player": "anonymous",
|
"anonymous_player": "anonymous",
|
||||||
"started_label": "Started",
|
"started_label": "Started",
|
||||||
"ended_label": "Ended",
|
"ended_label": "Ended",
|
||||||
"room_detail_title": "Room"
|
"room_detail_title": "Room",
|
||||||
|
"share_link": "Share this link to invite an opponent",
|
||||||
|
"copy_link": "Copy link",
|
||||||
|
"link_copied": "Copied!",
|
||||||
|
"scan_qr": "or scan the QR code",
|
||||||
|
"join_code_label": "Join by code",
|
||||||
|
"join_code_placeholder": "Room code",
|
||||||
|
"share_btn": "Share"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,7 @@
|
||||||
"hint_move": "Cliquez un champ surligné pour déplacer",
|
"hint_move": "Cliquez un champ surligné pour déplacer",
|
||||||
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
|
"hint_hold_or_go": "Tenir pour garder les points — S'en aller pour repartir",
|
||||||
"hint_continue": "Cliquez Continuer quand vous êtes prêt",
|
"hint_continue": "Cliquez Continuer quand vous êtes prêt",
|
||||||
|
"anonymous_name": "Anonyme",
|
||||||
"login_failed": "Identifiant ou mot de passe incorrect.",
|
"login_failed": "Identifiant ou mot de passe incorrect.",
|
||||||
"sign_in": "Se connecter",
|
"sign_in": "Se connecter",
|
||||||
"sign_out": "Se déconnecter",
|
"sign_out": "Se déconnecter",
|
||||||
|
|
@ -93,5 +94,12 @@
|
||||||
"anonymous_player": "anonyme",
|
"anonymous_player": "anonyme",
|
||||||
"started_label": "Début",
|
"started_label": "Début",
|
||||||
"ended_label": "Fin",
|
"ended_label": "Fin",
|
||||||
"room_detail_title": "Salle"
|
"room_detail_title": "Salle",
|
||||||
|
"share_link": "Partagez ce lien pour inviter un adversaire",
|
||||||
|
"copy_link": "Copier le lien",
|
||||||
|
"link_copied": "Copié !",
|
||||||
|
"scan_qr": "ou scannez le QR code",
|
||||||
|
"join_code_label": "Rejoindre par code",
|
||||||
|
"join_code_placeholder": "Code de salle",
|
||||||
|
"share_btn": "Partager"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ use gloo_storage::{LocalStorage, Storage};
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
use leptos::task::spawn_local;
|
use leptos::task::spawn_local;
|
||||||
use leptos_router::components::{Route, Router, Routes};
|
use leptos_router::components::{Route, Router, Routes};
|
||||||
|
use leptos_router::hooks::use_location;
|
||||||
use leptos_router::path;
|
use leptos_router::path;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
|
@ -11,15 +12,15 @@ use backbone_lib::session::{ConnectError, GameSession, RoomConfig, RoomRole, Ses
|
||||||
use backbone_lib::traits::ViewStateUpdate;
|
use backbone_lib::traits::ViewStateUpdate;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
|
use crate::i18n::*;
|
||||||
use crate::game::components::{ConnectingScreen, GameScreen};
|
use crate::game::components::{ConnectingScreen, GameScreen};
|
||||||
use crate::game::session::{
|
use crate::game::session::{
|
||||||
compute_last_moves, push_or_show, run_local_bot_game,
|
compute_last_moves, patch_player_name, push_or_show, run_local_bot_game,
|
||||||
};
|
};
|
||||||
use crate::game::trictrac::backend::TrictracBackend;
|
use crate::game::trictrac::backend::TrictracBackend;
|
||||||
use crate::game::trictrac::types::{
|
use crate::game::trictrac::types::{
|
||||||
GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState,
|
GameDelta, PlayerAction, ScoredEvent, SerStage, ViewState,
|
||||||
};
|
};
|
||||||
use crate::i18n::I18nContextProvider;
|
|
||||||
use crate::nav::SiteNav;
|
use crate::nav::SiteNav;
|
||||||
use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage};
|
use crate::portal::{account::AccountPage, game_detail::GameDetailPage, lobby::LobbyPage, profile::ProfilePage};
|
||||||
use trictrac_store::CheckerMove;
|
use trictrac_store::CheckerMove;
|
||||||
|
|
@ -128,6 +129,7 @@ async fn submit_game_result(room_code: String, game_state: ViewState) {
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn App() -> impl IntoView {
|
pub fn App() -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
let stored = load_session();
|
let stored = load_session();
|
||||||
let initial_screen = if stored.is_some() {
|
let initial_screen = if stored.is_some() {
|
||||||
Screen::Connecting
|
Screen::Connecting
|
||||||
|
|
@ -225,8 +227,10 @@ pub fn App() -> impl IntoView {
|
||||||
};
|
};
|
||||||
|
|
||||||
if remote_config.is_none() {
|
if remote_config.is_none() {
|
||||||
|
let player_name = auth_username.get_untracked()
|
||||||
|
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
||||||
loop {
|
loop {
|
||||||
let restart = run_local_bot_game(screen, &mut cmd_rx, pending).await;
|
let restart = run_local_bot_game(screen, &mut cmd_rx, pending, player_name.clone()).await;
|
||||||
if !restart {
|
if !restart {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
@ -266,7 +270,9 @@ pub fn App() -> impl IntoView {
|
||||||
let is_host = session.is_host;
|
let is_host = session.is_host;
|
||||||
let player_id = session.player_id;
|
let player_id = session.player_id;
|
||||||
let reconnect_token = session.reconnect_token;
|
let reconnect_token = session.reconnect_token;
|
||||||
let mut vs = ViewState::default_with_names("Blancs", "Noirs");
|
let my_name = auth_username.get_untracked()
|
||||||
|
.unwrap_or_else(|| t_string!(i18n, anonymous_name).to_string());
|
||||||
|
let mut vs = ViewState::default_with_names("", "");
|
||||||
let mut result_submitted = false;
|
let mut result_submitted = false;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -290,6 +296,7 @@ pub fn App() -> impl IntoView {
|
||||||
ViewStateUpdate::Full(state) => vs = state,
|
ViewStateUpdate::Full(state) => vs = state,
|
||||||
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
ViewStateUpdate::Incremental(delta) => vs.apply_delta(&delta),
|
||||||
}
|
}
|
||||||
|
patch_player_name(&mut vs, player_id, &my_name);
|
||||||
|
|
||||||
if is_host && !result_submitted && vs.stage == SerStage::Ended {
|
if is_host && !result_submitted && vs.stage == SerStage::Ended {
|
||||||
result_submitted = true;
|
result_submitted = true;
|
||||||
|
|
@ -343,42 +350,52 @@ pub fn App() -> impl IntoView {
|
||||||
});
|
});
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<I18nContextProvider>
|
<Router>
|
||||||
<Router>
|
<SiteNav />
|
||||||
// Nav: hidden while game overlay is active
|
|
||||||
<SiteNav />
|
|
||||||
|
|
||||||
// Portal pages — always mounted for router stability
|
<main>
|
||||||
<main>
|
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
||||||
<Routes fallback=|| view! { <p class="portal-empty" style="padding:3rem;text-align:center">"Page not found."</p> }>
|
<Route path=path!("/") view=LobbyPage />
|
||||||
<Route path=path!("/") view=LobbyPage />
|
<Route path=path!("/account") view=AccountPage />
|
||||||
<Route path=path!("/account") view=AccountPage />
|
<Route path=path!("/profile/:username") view=ProfilePage />
|
||||||
<Route path=path!("/profile/:username") view=ProfilePage />
|
<Route path=path!("/games/:id") view=GameDetailPage />
|
||||||
<Route path=path!("/games/:id") view=GameDetailPage />
|
</Routes>
|
||||||
</Routes>
|
</main>
|
||||||
</main>
|
|
||||||
|
|
||||||
// Game overlay: fixed, covers portal during play
|
<GameOverlay pending=pending screen=screen />
|
||||||
{move || {
|
</Router>
|
||||||
let q = pending.get();
|
}
|
||||||
let front = q.front().cloned();
|
}
|
||||||
if let Some(state) = front {
|
|
||||||
return view! {
|
/// Renders the full-screen game overlay, but only when the current route is "/".
|
||||||
<div class="game-overlay"><GameScreen state /></div>
|
/// This lets the user navigate to profile/account pages while a game is running.
|
||||||
}.into_any();
|
#[component]
|
||||||
}
|
fn GameOverlay(
|
||||||
match screen.get() {
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
Screen::Playing(state) => view! {
|
screen: RwSignal<Screen>,
|
||||||
<div class="game-overlay"><GameScreen state /></div>
|
) -> impl IntoView {
|
||||||
}.into_any(),
|
let location = use_location();
|
||||||
Screen::Connecting => view! {
|
|
||||||
<div class="game-overlay"><ConnectingScreen /></div>
|
move || {
|
||||||
}.into_any(),
|
if location.pathname.get() != "/" {
|
||||||
_ => view! { }.into_any(),
|
return view! { }.into_any();
|
||||||
}
|
}
|
||||||
}}
|
let q = pending.get();
|
||||||
</Router>
|
let front = q.front().cloned();
|
||||||
</I18nContextProvider>
|
if let Some(state) = front {
|
||||||
|
return view! {
|
||||||
|
<div class="game-overlay"><GameScreen state /></div>
|
||||||
|
}.into_any();
|
||||||
|
}
|
||||||
|
match screen.get() {
|
||||||
|
Screen::Playing(state) => view! {
|
||||||
|
<div class="game-overlay"><GameScreen state /></div>
|
||||||
|
}.into_any(),
|
||||||
|
Screen::Connecting => view! {
|
||||||
|
<div class="game-overlay"><ConnectingScreen /></div>
|
||||||
|
}.into_any(),
|
||||||
|
_ => view! { }.into_any(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice,
|
||||||
use super::die::Die;
|
use super::die::Die;
|
||||||
use crate::app::{GameUiState, NetCommand, PauseReason};
|
use crate::app::{GameUiState, NetCommand, PauseReason};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
use crate::portal::lobby::{qr_svg, room_url};
|
||||||
use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
|
use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage};
|
||||||
|
|
||||||
use super::board::Board;
|
use super::board::Board;
|
||||||
|
|
@ -223,6 +224,10 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
let opp_name_end = opp_score.name.clone();
|
let opp_name_end = opp_score.name.clone();
|
||||||
let opp_holes_end = opp_score.holes;
|
let opp_holes_end = opp_score.holes;
|
||||||
|
|
||||||
|
let share_open = RwSignal::new(false);
|
||||||
|
let share_url = if !is_bot_game { room_url(&room_id) } else { String::new() };
|
||||||
|
let share_svg = if !is_bot_game { qr_svg(&share_url) } else { String::new() };
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="game-container">
|
<div class="game-container">
|
||||||
// ── Top bar ──────────────────────────────────────────────────────
|
// ── Top bar ──────────────────────────────────────────────────────
|
||||||
|
|
@ -232,6 +237,18 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
} else {
|
} else {
|
||||||
t_string!(i18n, room_label, id = room_id.as_str())
|
t_string!(i18n, room_label, id = room_id.as_str())
|
||||||
}}</span>
|
}}</span>
|
||||||
|
|
||||||
|
{move || (!is_bot_game).then(|| view! {
|
||||||
|
<button
|
||||||
|
class="quit-link"
|
||||||
|
style="border:none;background:transparent;cursor:pointer"
|
||||||
|
on:click=move |_| share_open.update(|v| *v = !*v)
|
||||||
|
>
|
||||||
|
{move || if share_open.get() { "✕ " } else { "" }}
|
||||||
|
{t!(i18n, share_btn)}
|
||||||
|
</button>
|
||||||
|
})}
|
||||||
|
|
||||||
<div class="lang-switcher">
|
<div class="lang-switcher">
|
||||||
<button
|
<button
|
||||||
class:lang-active=move || i18n.get_locale() == Locale::en
|
class:lang-active=move || i18n.get_locale() == Locale::en
|
||||||
|
|
@ -243,9 +260,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
>"FR"</button>
|
>"FR"</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{move || auth_username.get().map(|u| view! {
|
{move || auth_username.get().map(|u| view! {
|
||||||
<span class="playing-as">"▶ " <strong>{u}</strong></span>
|
<span class="playing-as">"▶ " <strong>{u}</strong></span>
|
||||||
})}
|
})}
|
||||||
|
|
||||||
<a class="quit-link" href="#" on:click=move |e| {
|
<a class="quit-link" href="#" on:click=move |e| {
|
||||||
e.prevent_default();
|
e.prevent_default();
|
||||||
|
|
@ -253,6 +270,20 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
||||||
}>{t!(i18n, quit)}</a>
|
}>{t!(i18n, quit)}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// ── Share popover ─────────────────────────────────────────────────
|
||||||
|
{move || share_open.get().then(|| view! {
|
||||||
|
<div class="share-popover">
|
||||||
|
<p class="share-popover-label">{t!(i18n, share_link)}</p>
|
||||||
|
<div class="share-url-row">
|
||||||
|
<span class="share-url-text">{ share_url.clone() }</span>
|
||||||
|
</div>
|
||||||
|
<p class="share-popover-label" style="margin-top:0.75rem">
|
||||||
|
{t!(i18n, scan_qr)}
|
||||||
|
</p>
|
||||||
|
<div class="qr-container" inner_html=share_svg.clone() />
|
||||||
|
</div>
|
||||||
|
})}
|
||||||
|
|
||||||
// ── Opponent score (above board) ─────────────────────────────────
|
// ── Opponent score (above board) ─────────────────────────────────
|
||||||
<PlayerScorePanel score=opp_score is_you=false />
|
<PlayerScorePanel score=opp_score is_you=false />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,12 +18,13 @@ pub async fn run_local_bot_game(
|
||||||
screen: RwSignal<Screen>,
|
screen: RwSignal<Screen>,
|
||||||
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
cmd_rx: &mut mpsc::UnboundedReceiver<NetCommand>,
|
||||||
pending: RwSignal<VecDeque<GameUiState>>,
|
pending: RwSignal<VecDeque<GameUiState>>,
|
||||||
|
player_name: String,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
let mut backend = TrictracBackend::new(0);
|
let mut backend = TrictracBackend::new(0);
|
||||||
backend.player_arrival(0);
|
backend.player_arrival(0);
|
||||||
backend.player_arrival(1);
|
backend.player_arrival(1);
|
||||||
|
|
||||||
let mut vs = ViewState::default_with_names("You", "Bot");
|
let mut vs = ViewState::default_with_names(&player_name, "Bot");
|
||||||
for cmd in backend.drain_commands() {
|
for cmd in backend.drain_commands() {
|
||||||
match cmd {
|
match cmd {
|
||||||
BackendCommand::ResetViewState => {
|
BackendCommand::ResetViewState => {
|
||||||
|
|
@ -35,6 +36,7 @@ pub async fn run_local_bot_game(
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
patch_bot_names(&mut vs, &player_name);
|
||||||
screen.set(Screen::Playing(GameUiState {
|
screen.set(Screen::Playing(GameUiState {
|
||||||
view_state: vs.clone(),
|
view_state: vs.clone(),
|
||||||
player_id: 0,
|
player_id: 0,
|
||||||
|
|
@ -58,6 +60,7 @@ pub async fn run_local_bot_game(
|
||||||
vs.apply_delta(&delta);
|
vs.apply_delta(&delta);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
patch_bot_names(&mut vs, &player_name);
|
||||||
let scored = compute_scored_event(&prev_vs, &vs, 0);
|
let scored = compute_scored_event(&prev_vs, &vs, 0);
|
||||||
let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
|
let opp_scored = compute_scored_event(&prev_vs, &vs, 1);
|
||||||
screen.set(Screen::Playing(GameUiState {
|
screen.set(Screen::Playing(GameUiState {
|
||||||
|
|
@ -86,6 +89,7 @@ pub async fn run_local_bot_game(
|
||||||
if let BackendCommand::Delta(delta) = cmd {
|
if let BackendCommand::Delta(delta) = cmd {
|
||||||
let delta_prev_vs = vs.clone();
|
let delta_prev_vs = vs.clone();
|
||||||
vs.apply_delta(&delta);
|
vs.apply_delta(&delta);
|
||||||
|
patch_bot_names(&mut vs, &player_name);
|
||||||
push_or_show(
|
push_or_show(
|
||||||
&delta_prev_vs,
|
&delta_prev_vs,
|
||||||
GameUiState {
|
GameUiState {
|
||||||
|
|
@ -110,6 +114,17 @@ pub async fn run_local_bot_game(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patches the player names in a ViewState after a backend delta (bot game: slot 0 = human, 1 = Bot).
|
||||||
|
pub fn patch_bot_names(vs: &mut ViewState, player_name: &str) {
|
||||||
|
vs.scores[0].name = player_name.to_string();
|
||||||
|
vs.scores[1].name = "Bot".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patches the local player's name in a ViewState after a backend delta (multiplayer).
|
||||||
|
pub fn patch_player_name(vs: &mut ViewState, player_id: u16, name: &str) {
|
||||||
|
vs.scores[player_id as usize].name = name.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
/// Returns the checker moves to animate when the board changed between two ViewStates.
|
||||||
pub fn compute_last_moves(
|
pub fn compute_last_moves(
|
||||||
prev: &ViewState,
|
prev: &ViewState,
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,13 @@ mod nav;
|
||||||
mod portal;
|
mod portal;
|
||||||
|
|
||||||
use app::App;
|
use app::App;
|
||||||
|
use i18n::I18nContextProvider;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
mount_to_body(|| view! { <App /> })
|
mount_to_body(|| view! {
|
||||||
|
<I18nContextProvider>
|
||||||
|
<App />
|
||||||
|
</I18nContextProvider>
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,14 @@ use leptos::task::spawn_local;
|
||||||
use leptos_router::components::A;
|
use leptos_router::components::A;
|
||||||
|
|
||||||
use crate::api;
|
use crate::api;
|
||||||
use crate::app::Screen;
|
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn SiteNav() -> impl IntoView {
|
pub fn SiteNav() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
let i18n = use_i18n();
|
||||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
|
||||||
let auth_username =
|
let auth_username =
|
||||||
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
use_context::<RwSignal<Option<String>>>().expect("auth_username context not found");
|
||||||
|
|
||||||
let is_game_active =
|
|
||||||
move || !matches!(screen.get(), Screen::Login { .. });
|
|
||||||
|
|
||||||
let logout = move |_| {
|
let logout = move |_| {
|
||||||
spawn_local(async move {
|
spawn_local(async move {
|
||||||
let _ = api::post_logout().await;
|
let _ = api::post_logout().await;
|
||||||
|
|
@ -24,7 +19,7 @@ pub fn SiteNav() -> impl IntoView {
|
||||||
};
|
};
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<nav class="site-nav" class:hidden=is_game_active>
|
<nav class="site-nav">
|
||||||
<A href="/" attr:class="site-nav-brand">"Trictrac"</A>
|
<A href="/" attr:class="site-nav-brand">"Trictrac"</A>
|
||||||
<div class="site-nav-spacer" />
|
<div class="site-nav-spacer" />
|
||||||
<div class="lang-switcher">
|
<div class="lang-switcher">
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,103 @@
|
||||||
use futures::channel::mpsc::UnboundedSender;
|
use futures::channel::mpsc::UnboundedSender;
|
||||||
use leptos::prelude::*;
|
use leptos::prelude::*;
|
||||||
|
use leptos_router::hooks::use_query_map;
|
||||||
|
|
||||||
use crate::app::{NetCommand, Screen};
|
use crate::app::{NetCommand, Screen};
|
||||||
use crate::i18n::*;
|
use crate::i18n::*;
|
||||||
|
|
||||||
|
// ── Room code generation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
fn generate_room_code() -> String {
|
||||||
|
use rand::Rng;
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
const CHARS: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
|
||||||
|
(0..6)
|
||||||
|
.map(|_| CHARS[rng.random_range(0..CHARS.len())] as char)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── QR code SVG rendering ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub(crate) fn qr_svg(text: &str) -> String {
|
||||||
|
use qrcodegen::{QrCode, QrCodeEcc};
|
||||||
|
let qr = match QrCode::encode_text(text, QrCodeEcc::Medium) {
|
||||||
|
Ok(q) => q,
|
||||||
|
Err(_) => return String::new(),
|
||||||
|
};
|
||||||
|
let size = qr.size();
|
||||||
|
let border = 2;
|
||||||
|
let total = size + 2 * border;
|
||||||
|
let mut svg = format!(
|
||||||
|
"<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 {t} {t}\" shape-rendering=\"crispEdges\">",
|
||||||
|
t = total,
|
||||||
|
);
|
||||||
|
svg.push_str("<rect width=\"100%\" height=\"100%\" fill=\"#f2e8d0\"/>");
|
||||||
|
for y in 0..size {
|
||||||
|
for x in 0..size {
|
||||||
|
if qr.get_module(x, y) {
|
||||||
|
svg.push_str(&format!(
|
||||||
|
"<rect x=\"{}\" y=\"{}\" width=\"1\" height=\"1\" fill=\"#2a1508\"/>",
|
||||||
|
x + border,
|
||||||
|
y + border,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
svg.push_str("</svg>");
|
||||||
|
svg
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Share URL helper ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
pub(crate) fn room_url(code: &str) -> String {
|
||||||
|
let origin = web_sys::window()
|
||||||
|
.and_then(|w| w.location().origin().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
format!("{}/?room={}", origin, code)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(target_arch = "wasm32"))]
|
||||||
|
pub(crate) fn room_url(code: &str) -> String {
|
||||||
|
format!("http://localhost:9091/?room={}", code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Lobby component ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
enum LobbyView {
|
||||||
|
Idle,
|
||||||
|
Waiting { code: String },
|
||||||
|
}
|
||||||
|
|
||||||
#[component]
|
#[component]
|
||||||
pub fn LobbyPage() -> impl IntoView {
|
pub fn LobbyPage() -> impl IntoView {
|
||||||
let i18n = use_i18n();
|
|
||||||
let (room_name, set_room_name) = signal(String::new());
|
|
||||||
|
|
||||||
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
let screen = use_context::<RwSignal<Screen>>().expect("Screen context not found");
|
||||||
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
let cmd_tx = use_context::<UnboundedSender<NetCommand>>()
|
||||||
.expect("UnboundedSender<NetCommand> not found in context");
|
.expect("UnboundedSender<NetCommand> not found in context");
|
||||||
|
let query = use_query_map();
|
||||||
|
|
||||||
let cmd_tx_create = cmd_tx.clone();
|
let view_state: RwSignal<LobbyView> = RwSignal::new(LobbyView::Idle);
|
||||||
let cmd_tx_join = cmd_tx.clone();
|
|
||||||
let cmd_tx_bot = cmd_tx;
|
// Auto-join when the URL contains ?room=CODE
|
||||||
|
let cmd_tx_query = 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Extract connection error from screen state.
|
|
||||||
let error = move || match screen.get() {
|
let error = move || match screen.get() {
|
||||||
Screen::Login { error } => error,
|
Screen::Login { error } => error,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let cmd_tx_idle = cmd_tx;
|
||||||
|
|
||||||
view! {
|
view! {
|
||||||
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
<div class="portal-main" style="display:flex;justify-content:center;align-items:flex-start;padding-top:5vh">
|
||||||
<div class="login-card">
|
<div class="login-card">
|
||||||
|
|
@ -34,55 +109,157 @@ pub fn LobbyPage() -> impl IntoView {
|
||||||
<p class="login-subtitle">
|
<p class="login-subtitle">
|
||||||
<em>"Une interprétation numérique"</em>
|
<em>"Une interprétation numérique"</em>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div class="login-ornament">"✦"</div>
|
<div class="login-ornament">"✦"</div>
|
||||||
|
|
||||||
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
{move || error().map(|err| view! { <p class="error-msg">{err}</p> })}
|
||||||
|
|
||||||
<input
|
{move || match view_state.get() {
|
||||||
class="login-input"
|
LobbyView::Idle => {
|
||||||
type="text"
|
// Create fresh closures each render so they are FnMut-compatible
|
||||||
placeholder=move || t_string!(i18n, room_name_placeholder)
|
let cmd_tx_create = cmd_tx_idle.clone();
|
||||||
prop:value=move || room_name.get()
|
let cmd_tx_bot = cmd_tx_idle.clone();
|
||||||
on:input=move |ev| set_room_name.set(event_target_value(&ev))
|
let on_create = move |_: leptos::ev::MouseEvent| {
|
||||||
/>
|
let code = generate_room_code();
|
||||||
|
|
||||||
<div class="login-actions">
|
|
||||||
<button
|
|
||||||
class="login-btn login-btn-primary"
|
|
||||||
disabled=move || room_name.get().is_empty()
|
|
||||||
on:click=move |_| {
|
|
||||||
cmd_tx_create
|
cmd_tx_create
|
||||||
.unbounded_send(NetCommand::CreateRoom { room: room_name.get() })
|
.unbounded_send(NetCommand::CreateRoom { room: code.clone() })
|
||||||
.ok();
|
.ok();
|
||||||
}
|
view_state.set(LobbyView::Waiting { code });
|
||||||
>
|
};
|
||||||
{t!(i18n, create_room)}
|
view! {
|
||||||
</button>
|
<IdleCard on_create=on_create cmd_tx_bot=cmd_tx_bot />
|
||||||
|
}.into_any()
|
||||||
<button
|
}
|
||||||
class="login-btn login-btn-secondary"
|
LobbyView::Waiting { code } => view! {
|
||||||
disabled=move || room_name.get().is_empty()
|
<WaitingCard code=code />
|
||||||
on:click=move |_| {
|
}.into_any(),
|
||||||
cmd_tx_join
|
}}
|
||||||
.unbounded_send(NetCommand::JoinRoom { room: room_name.get() })
|
|
||||||
.ok();
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t!(i18n, join_room)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
class="login-btn login-btn-bot"
|
|
||||||
on:click=move |_| {
|
|
||||||
cmd_tx_bot.unbounded_send(NetCommand::PlayVsBot).ok();
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t!(i18n, play_vs_bot)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Idle card: Create + vs Bot + hidden join-by-code ─────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn IdleCard(
|
||||||
|
on_create: impl Fn(leptos::ev::MouseEvent) + 'static,
|
||||||
|
cmd_tx_bot: UnboundedSender<NetCommand>,
|
||||||
|
) -> 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();
|
||||||
|
|
||||||
|
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(); }
|
||||||
|
>
|
||||||
|
{t!(i18n, play_vs_bot)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
// Hidden "join by code" fallback
|
||||||
|
<div style="margin-top:1.25rem;text-align:center">
|
||||||
|
<button
|
||||||
|
class="portal-page-btn"
|
||||||
|
style="font-size:0.75rem;opacity:0.7"
|
||||||
|
on:click=move |_| join_open.update(|v| *v = !*v)
|
||||||
|
>
|
||||||
|
{move || if join_open.get() { "▲ " } else { "▼ " }}
|
||||||
|
{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();
|
||||||
|
join_open.get().then(|| view! {
|
||||||
|
<div style="margin-top:0.75rem;display:flex;gap:0.5rem">
|
||||||
|
<input
|
||||||
|
class="login-input"
|
||||||
|
style="flex:1;margin:0"
|
||||||
|
type="text"
|
||||||
|
placeholder=move || t_string!(i18n, join_code_placeholder)
|
||||||
|
prop:value=move || join_code.get()
|
||||||
|
on:input=move |ev| join_code.set(event_target_value(&ev))
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="login-btn login-btn-secondary"
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t!(i18n, join_room)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Waiting card: URL + copy + QR ────────────────────────────────────────────
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn WaitingCard(code: String) -> impl IntoView {
|
||||||
|
let i18n = use_i18n();
|
||||||
|
let url = room_url(&code);
|
||||||
|
let svg = qr_svg(&url);
|
||||||
|
let copied = RwSignal::new(false);
|
||||||
|
|
||||||
|
let on_copy = {
|
||||||
|
let url = url.clone();
|
||||||
|
move |_| {
|
||||||
|
#[cfg(target_arch = "wasm32")]
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
copied.set(true);
|
||||||
|
gloo_timers::future::TimeoutFuture::new(2000).await;
|
||||||
|
copied.set(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
view! {
|
||||||
|
<p style="font-size:0.85rem;color:rgba(242,232,208,0.75);margin-bottom:1rem;text-align:center">
|
||||||
|
{t!(i18n, waiting_for_opponent)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p style="font-size:0.8rem;color:rgba(242,232,208,0.6);margin-bottom:0.5rem;text-align:center">
|
||||||
|
{t!(i18n, share_link)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="share-url-row">
|
||||||
|
<span class="share-url-text">{ url.clone() }</span>
|
||||||
|
<button class="share-copy-btn" on:click=on_copy>
|
||||||
|
{move || if copied.get() {
|
||||||
|
t_string!(i18n, link_copied)
|
||||||
|
} else {
|
||||||
|
t_string!(i18n, copy_link)
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p style="font-size:0.75rem;color:rgba(242,232,208,0.45);margin:1rem 0 0.5rem;text-align:center">
|
||||||
|
{t!(i18n, scan_qr)}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="qr-container" inner_html=svg />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue