feat(web client): free play mode

This commit is contained in:
Henri Bourcereau 2026-05-26 18:12:13 +02:00
parent f459021f22
commit 486649a599
10 changed files with 357 additions and 63 deletions

10
Cargo.lock generated
View file

@ -189,7 +189,7 @@ dependencies = [
[[package]] [[package]]
name = "backbone-lib" name = "backbone-lib"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"bytes", "bytes",
"ewebsock", "ewebsock",
@ -2658,7 +2658,7 @@ dependencies = [
[[package]] [[package]]
name = "protocol" name = "protocol"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"serde", "serde",
] ]
@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
[[package]] [[package]]
name = "relay-server" name = "relay-server"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"argon2", "argon2",
"axum", "axum",
@ -3921,7 +3921,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-store" name = "trictrac-store"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"base64 0.21.7", "base64 0.21.7",
@ -3934,7 +3934,7 @@ dependencies = [
[[package]] [[package]]
name = "trictrac-web" name = "trictrac-web"
version = "0.2.13" version = "0.2.15"
dependencies = [ dependencies = [
"backbone-lib", "backbone-lib",
"futures", "futures",

View file

@ -1855,6 +1855,58 @@ a:hover { text-decoration: underline; }
min-height: 2rem; min-height: 2rem;
} }
/* ── Free-play mode ─────────────────────────────────────────────────────── */
.free-mode-toggle {
display: flex;
align-items: center;
gap: 0.4rem;
font-family: var(--font-ui);
font-size: 0.78rem;
color: #887766;
cursor: pointer;
user-select: none;
padding-top: 0.1rem;
}
.free-mode-toggle input[type="checkbox"] {
accent-color: var(--ui-gold);
cursor: pointer;
width: 0.85rem;
height: 0.85rem;
}
.free-mode-help {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1rem;
height: 1rem;
border-radius: 50%;
border: 1px solid #a89880;
font-size: 0.65rem;
font-style: normal;
color: #a89880;
cursor: help;
flex-shrink: 0;
}
.free-mode-error {
display: flex;
align-items: center;
gap: 0.75rem;
background: rgba(180, 60, 30, 0.12);
border: 1px solid rgba(180, 60, 30, 0.4);
border-radius: 4px;
padding: 0.4rem 0.75rem;
width: 100%;
box-sizing: border-box;
}
.free-mode-error-msg {
flex: 1;
font-family: var(--font-ui);
font-size: 0.85rem;
color: #8b2000;
font-style: italic;
}
/* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ /* ── Pre-game ceremony overlay ──────────────────────────────────────────── */
.ceremony-overlay { .ceremony-overlay {
position: fixed; position: fixed;

View file

@ -149,5 +149,19 @@
"delete_account_mismatch": "Username does not match.", "delete_account_mismatch": "Username does not match.",
"account_deleted": "Your account has been permanently deleted.", "account_deleted": "Your account has been permanently deleted.",
"about": "About", "about": "About",
"legal": "Legal notices" "legal": "Legal notices",
"free_mode_label": "Free play mode",
"free_mode_tooltip": "Select any checker and try to find a valid move yourself. If your move breaks a rule, you'll see an explanation.",
"reset_move": "Try again",
"err_invalid_move": "This move is not valid with the current dice",
"err_opponent_corner": "Cannot land on the opponent's rest corner",
"err_corner_needs_two": "Must enter and leave the rest corner with 2 checkers at once",
"err_corner_by_effect": "Must take the rest corner directly (by effect), not by force",
"err_exit_needs_all_in_last_jan": "All checkers must be in the last jan before exiting",
"err_exit_by_effect": "Must exit with exact dice value when possible (no overage)",
"err_exit_not_farthest": "With overage, must exit the checker farthest from the exit",
"err_opponent_can_fill_quarter": "Cannot play in a quarter the opponent can still fill",
"err_must_fill_quarter": "Must fill (or keep) a quarter when possible",
"err_must_play_all_dice": "Must play both dice when possible",
"err_must_play_stronger_die": "Must play the stronger die when only one can be played"
} }

View file

@ -147,5 +147,19 @@
"delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.", "delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.",
"account_deleted": "Votre compte a été définitivement supprimé.", "account_deleted": "Votre compte a été définitivement supprimé.",
"about": "À propos", "about": "À propos",
"legal": "Mentions légales" "legal": "Mentions légales",
"free_mode_label": "Mode jeu libre",
"free_mode_tooltip": "Sélectionnez n'importe quelle dame et tentez de trouver un coup valide vous-même. Si votre coup enfreint une règle, une explication s'affichera.",
"reset_move": "Réessayer",
"err_invalid_move": "Ce coup n'est pas valide avec les dés actuels",
"err_opponent_corner": "Interdit de jouer sur le coin de repos adverse",
"err_corner_needs_two": "Le coin de repos doit être pris et quitté avec 2 dames simultanément",
"err_corner_by_effect": "Doit prendre le coin de repos par effet, non par puissance",
"err_exit_needs_all_in_last_jan": "Toutes les dames doivent être dans le jan de retour avant de sortir",
"err_exit_by_effect": "Doit sortir par effet (sans excédant) si c'est possible",
"err_exit_not_farthest": "Avec excédant, doit sortir la dame la plus éloignée de la sortie",
"err_opponent_can_fill_quarter": "Interdit de jouer dans un cadran que l'adversaire peut encore remplir",
"err_must_fill_quarter": "Doit remplir (ou conserver) un cadran si c'est possible",
"err_must_play_all_dice": "Doit jouer les deux dés si c'est possible",
"err_must_play_stronger_die": "Doit jouer le dé le plus fort quand un seul peut être joué"
} }

View file

@ -4,7 +4,7 @@
This site does not use third-party analytics or advertising trackers. This site does not use third-party analytics or advertising trackers.
If you create an account, your username, email address, and argon2-hashed password are stored in a database on our server. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties. If you create an account, your username, email address, and argon2-hashed password are stored in the database. Your email address is used only to send you account-related messages (email verification, password reset). It is never shared with third parties.
Game records (room codes, move history, outcomes) may be stored to display game history on your profile page. Game records (room codes, move history, outcomes) may be stored to display game history on your profile page.

View file

@ -4,7 +4,7 @@
Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires. Ce site n'utilise pas d'outils d'analyse tiers ni de traceurs publicitaires.
Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés dans une base de données sur notre serveur. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers. Si vous créez un compte, votre nom d'utilisateur, votre adresse e-mail et votre mot de passe haché (argon2) sont stockés en base de données. Votre adresse e-mail est utilisée uniquement pour vous envoyer des messages liés à votre compte (vérification d'e-mail, réinitialisation de mot de passe). Elle n'est jamais partagée avec des tiers.
Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil. Les enregistrements de parties (codes de salle, historique des coups, résultats) peuvent être conservés afin d'afficher l'historique des parties sur votre page de profil.

View file

@ -246,6 +246,86 @@ fn valid_dests_for(
v v
} }
/// In free-mode: all fields that own a checker (after staged moves applied).
fn free_mode_origins_for(board: [i8; 24], staged: &[(u8, u8)], is_white: bool) -> Vec<u8> {
(1u8..=24)
.filter(|&f| {
let v = displayed_value(board, staged, is_white, f);
if is_white { v > 0 } else { v < 0 }
})
.collect()
}
/// In free-mode: destinations reachable from `origin` by the remaining die value,
/// excluding fields occupied by opponent checkers.
fn free_mode_dests_for(
board: [i8; 24],
staged: &[(u8, u8)],
origin: u8,
dice: (u8, u8),
is_white: bool,
all_in_exit: bool,
) -> Vec<u8> {
let to_use: Vec<u8> = match staged.len() {
0 => {
if dice.0 == dice.1 {
vec![dice.0]
} else {
vec![dice.0, dice.1]
}
}
1 => {
let &(f0, t0) = &staged[0];
if t0 == 0 {
// First move was an exit — can't reliably infer die, offer both
if dice.0 == dice.1 { vec![dice.0] } else { vec![dice.0, dice.1] }
} else {
let dist: u8 = if is_white {
t0.saturating_sub(f0)
} else {
f0.saturating_sub(t0)
};
if dice.0 == dice.1 {
vec![dice.0]
} else if dist == dice.0 {
vec![dice.1]
} else {
vec![dice.0]
}
}
}
_ => return vec![],
};
let opp_present = |f: u8| -> bool {
let v = displayed_value(board, staged, is_white, f);
if is_white { v < 0 } else { v > 0 }
};
let mut dests = vec![];
for die in to_use {
if die == 0 {
continue;
}
let dest: i16 = if is_white {
origin as i16 + die as i16
} else {
origin as i16 - die as i16
};
if dest >= 1 && dest <= 24 {
let d = dest as u8;
if !opp_present(d) {
dests.push(d);
}
} else if all_in_exit {
dests.push(0); // exit
}
}
dests.sort_unstable();
dests.dedup();
dests
}
#[component] #[component]
pub fn Board( pub fn Board(
view_state: ViewState, view_state: ViewState,
@ -275,8 +355,13 @@ pub fn Board(
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed). /// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
#[prop(default = false)] #[prop(default = false)]
suppress_dice_anim: bool, suppress_dice_anim: bool,
/// When true, any field with own checkers is selectable as origin; destinations
/// are computed from dice arithmetic rather than from pre-validated sequences.
#[prop(default = RwSignal::new(false))]
free_mode: RwSignal<bool>,
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let vs_dice = view_state.dice;
let white_points = view_state.scores[0].points; let white_points = view_state.scores[0].points;
let white_can_bredouille = view_state.scores[0].can_bredouille; let white_can_bredouille = view_state.scores[0].can_bredouille;
let black_points = view_state.scores[1].points; let black_points = view_state.scores[1].points;
@ -389,6 +474,23 @@ pub fn Board(
if can_stage && sel.is_some() && sel != Some(field_num) { if can_stage && sel.is_some() && sel != Some(field_num) {
cls.push_str(" dest"); cls.push_str(" dest");
} }
} else if can_stage && free_mode.get() {
// Free-play mode: highlight based on dice arithmetic
if let Some(origin) = sel {
if origin == field_num {
cls.push_str(" selected clickable");
} else {
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if dests.iter().any(|&d| d == field_num && d != 0) {
cls.push_str(" clickable dest");
}
}
} else {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) {
cls.push_str(" clickable");
}
}
} else if can_stage { } else if can_stage {
if let Some(origin) = sel { if let Some(origin) = sel {
if origin == field_num { if origin == field_num {
@ -430,40 +532,54 @@ pub fn Board(
let staged = staged_moves.get_untracked(); let staged = staged_moves.get_untracked();
if staged.len() >= 2 { return; } if staged.len() >= 2 { return; }
match selected_origin.get_untracked() { if free_mode.get_untracked() {
Some(origin) if origin == field_num => { match selected_origin.get_untracked() {
selected_origin.set(None); Some(origin) if origin == field_num => {
}
Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None); selected_origin.set(None);
} }
} Some(origin) => {
None => { let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
if seqs_k.is_empty() { if dests.iter().any(|&d| d == field_num) {
let val = displayed_value(board, &staged, is_white, field_num); staged_moves.update(|v| v.push((origin, field_num)));
if is_white && val > 0 || !is_white && val < 0 { selected_origin.set(None);
selected_origin.set(Some(field_num));
} }
} else { }
let origins = valid_origins_for(&seqs_k, &staged); None => {
let origins = free_mode_origins_for(board, &staged, is_white);
if origins.iter().any(|&o| o == field_num) { if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num)); selected_origin.set(Some(field_num));
// let dests = valid_dests_for(&seqs_k, &staged, field_num); }
// if !dests.is_empty() && dests.iter().all(|&d| d == 0) { }
// // All destinations are exits: auto-stage }
// staged_moves.update(|v| v.push((field_num, 0))); } else {
// } else { match selected_origin.get_untracked() {
// selected_origin.set(Some(field_num)); Some(origin) if origin == field_num => {
// } selected_origin.set(None);
}
Some(origin) => {
let valid = if seqs_k.is_empty() {
true
} else {
valid_dests_for(&seqs_k, &staged, origin)
.iter()
.any(|&d| d == field_num)
};
if valid {
staged_moves.update(|v| v.push((origin, field_num)));
selected_origin.set(None);
}
}
None => {
if seqs_k.is_empty() {
let val = displayed_value(board, &staged, is_white, field_num);
if is_white && val > 0 || !is_white && val < 0 {
selected_origin.set(Some(field_num));
}
} else {
let origins = valid_origins_for(&seqs_k, &staged);
if origins.iter().any(|&o| o == field_num) {
selected_origin.set(Some(field_num));
}
} }
} }
} }
@ -624,15 +740,20 @@ pub fn Board(
// even when the initial board has a checker outside the exit zone, // even when the initial board has a checker outside the exit zone,
// because the first move can bring all checkers in (e.g. 15→21, 19→exit). // because the first move can bring all checkers in (e.g. 15→21, 19→exit).
let staged = staged_moves.get(); let staged = staged_moves.get();
let show = is_move_stage && match staged.len() { let show = is_move_stage && if free_mode.get() {
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0), // In free mode show exit button whenever all checkers are in exit zone
1 => { all_in_exit && staged.len() < 2
let (f0, t0) = staged[0]; } else {
seqs_exit.iter() match staged.len() {
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0) 0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || m2.get_to() == 0),
.any(|(_, m2)| m2.get_to() == 0) 1 => {
let (f0, t0) = staged[0];
seqs_exit.iter()
.filter(|(m1, _)| m1.get_from() as u8 == f0 && m1.get_to() as u8 == t0)
.any(|(_, m2)| m2.get_to() == 0)
}
_ => false,
} }
_ => false,
}; };
show.then(|| { show.then(|| {
let seqs_exit_cls = seqs_exit.clone(); let seqs_exit_cls = seqs_exit.clone();
@ -657,10 +778,15 @@ pub fn Board(
let staged = staged_moves.get(); let staged = staged_moves.get();
let sel = selected_origin.get(); let sel = selected_origin.get();
let active = match sel { let active = match sel {
Some(origin) => seqs_exit_cls.is_empty() Some(origin) => if free_mode.get() {
|| valid_dests_for(&seqs_exit_cls, &staged, origin) free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter() .iter().any(|&d| d == 0)
.any(|&d| d == 0), } else {
seqs_exit_cls.is_empty()
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
.iter()
.any(|&d| d == 0)
},
None => false, None => false,
}; };
if active { "exit-btn exit-active" } else { "exit-btn" } if active { "exit-btn exit-active" } else { "exit-btn" }
@ -672,10 +798,15 @@ pub fn Board(
let Some(origin) = selected_origin.get_untracked() else { let Some(origin) = selected_origin.get_untracked() else {
return; return;
}; };
let valid = seqs_exit_click.is_empty() let valid = if free_mode.get_untracked() {
|| valid_dests_for(&seqs_exit_click, &staged, origin) free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
.iter() .iter().any(|&d| d == 0)
.any(|&d| d == 0); } else {
seqs_exit_click.is_empty()
|| valid_dests_for(&seqs_exit_click, &staged, origin)
.iter()
.any(|&d| d == 0)
};
if valid { if valid {
staged_moves.update(|v| v.push((origin, 0))); staged_moves.update(|v| v.push((origin, 0)));
selected_origin.set(None); selected_origin.set(None);

View file

@ -2,8 +2,9 @@ use std::cell::Cell;
use std::collections::VecDeque; use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
use gloo_storage::Storage as _;
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules}; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveError, MoveRules};
use super::die::Die; use super::die::Die;
use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::app::{GameUiState, NetCommand, PauseReason};
@ -20,6 +21,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let i18n = use_i18n(); let i18n = use_i18n();
let vs = state.view_state.clone(); let vs = state.view_state.clone();
let vs_board = vs.board;
let vs_dice = vs.dice;
let player_id = state.player_id; let player_id = state.player_id;
let is_my_turn = vs.active_mp_player == Some(player_id); let is_my_turn = vs.active_mp_player == Some(player_id);
let is_move_stage = is_my_turn let is_move_stage = is_my_turn
@ -49,6 +52,19 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
// when the Effect also writes to the same signal it reads). // when the Effect also writes to the same signal it reads).
let prev_staged_len = Cell::new(0usize); let prev_staged_len = Cell::new(0usize);
// ── Free-play mode ─────────────────────────────────────────────────────────
// When enabled the board shows all own-checker fields as valid origins and
// invalid moves produce an explanatory error rather than being suppressed.
fn load_free_mode() -> bool {
gloo_storage::LocalStorage::get::<bool>("trictrac_free_mode").unwrap_or(false)
}
fn save_free_mode(val: bool) {
gloo_storage::LocalStorage::set("trictrac_free_mode", val).ok();
}
let free_mode: RwSignal<bool> = RwSignal::new(load_free_mode());
// None = no error; Some(None) = generic invalid; Some(Some(e)) = specific rule error
let move_error: RwSignal<Option<Option<MoveError>>> = RwSignal::new(None);
Effect::new(move |_| { Effect::new(move |_| {
let moves = staged_moves.get(); let moves = staged_moves.get();
let n = moves.len(); let n = moves.len();
@ -61,12 +77,36 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let to_cm = |&(from, to): &(u8, u8)| { let to_cm = |&(from, to): &(u8, u8)| {
CheckerMove::new(from as usize, to as usize).unwrap_or_default() CheckerMove::new(from as usize, to as usize).unwrap_or_default()
}; };
cmd_tx_effect let m1 = to_cm(&moves[0]);
.unbounded_send(NetCommand::Action(PlayerAction::Move( let m2 = to_cm(&moves[1]);
to_cm(&moves[0]),
to_cm(&moves[1]), if free_mode.get_untracked() {
))) // Mirror moves to White-perspective for validation (MoveRules always works as White)
.ok(); let (vm1, vm2) = if player_id == 0 {
(m1, m2)
} else {
(m1.mirror(), m2.mirror())
};
let mut store_board = StoreBoard::new();
store_board.set_positions(&Color::White, vs_board);
let store_dice = StoreDice { values: vs_dice };
let color = if player_id == 0 { Color::White } else { Color::Black };
let rules = MoveRules::new(&color, &store_board, store_dice);
if rules.moves_follow_rules(&(vm1, vm2)) {
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
.ok();
} else {
// moves_allowed gives the specific TricTrac rule that was broken (if any)
let specific_err = rules.moves_allowed(&(vm1, vm2)).err();
move_error.set(Some(specific_err));
}
} else {
cmd_tx_effect
.unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2)))
.ok();
}
staged_moves.set(vec![]); staged_moves.set(vec![]);
selected_origin.set(None); selected_origin.set(None);
// Reset the counter so the next turn starts clean. // Reset the counter so the next turn starts clean.
@ -344,6 +384,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
last_moves=last_moves last_moves=last_moves
hit_fields=hit_fields hit_fields=hit_fields
suppress_dice_anim=suppress_dice_anim suppress_dice_anim=suppress_dice_anim
free_mode=free_mode
/> />
// ── Status, hints, and actions — cream strip below board ─ // ── Status, hints, and actions — cream strip below board ─
@ -385,6 +426,33 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}; };
(!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> }) (!hint.is_empty()).then(|| view! { <p class="game-sub-prompt">{hint}</p> })
}} }}
// ── Free-mode error banner ─────────────────────────────────────
{move || {
move_error.get().map(|opt_err| {
let msg: String = match opt_err {
None => t_string!(i18n, err_invalid_move).to_owned(),
Some(MoveError::OpponentCorner) => t_string!(i18n, err_opponent_corner).to_owned(),
Some(MoveError::CornerNeedsTwoCheckers) => t_string!(i18n, err_corner_needs_two).to_owned(),
Some(MoveError::CornerByEffectPossible) => t_string!(i18n, err_corner_by_effect).to_owned(),
Some(MoveError::ExitNeedsAllCheckersOnLastQuarter) => t_string!(i18n, err_exit_needs_all_in_last_jan).to_owned(),
Some(MoveError::ExitByEffectPossible) => t_string!(i18n, err_exit_by_effect).to_owned(),
Some(MoveError::ExitNotFarthest) => t_string!(i18n, err_exit_not_farthest).to_owned(),
Some(MoveError::OpponentCanFillQuarter) => t_string!(i18n, err_opponent_can_fill_quarter).to_owned(),
Some(MoveError::MustFillQuarter) => t_string!(i18n, err_must_fill_quarter).to_owned(),
Some(MoveError::MustPlayAllDice) => t_string!(i18n, err_must_play_all_dice).to_owned(),
Some(MoveError::MustPlayStrongerDie) => t_string!(i18n, err_must_play_stronger_die).to_owned(),
};
view! {
<div class="free-mode-error">
<span class="free-mode-error-msg">{msg}</span>
<button
class="btn btn-secondary"
on:click=move |_| { move_error.set(None); }
>{t!(i18n, reset_move)}</button>
</div>
}
})
}}
<div class="board-actions"> <div class="board-actions">
{waiting_for_confirm.then(|| view! { {waiting_for_confirm.then(|| view! {
<button class="btn btn-primary" on:click=move |_| { <button class="btn btn-primary" on:click=move |_| {
@ -436,6 +504,21 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
}) })
}} }}
</div> </div>
// ── Free-play mode toggle ─────────────────────────────────────
<label class="free-mode-toggle">
<input
type="checkbox"
prop:checked=move || free_mode.get()
on:change=move |ev| {
let v = event_target_checked(&ev);
save_free_mode(v);
free_mode.set(v);
move_error.set(None);
}
/>
{t!(i18n, free_mode_label)}
<span class="free-mode-help" title=move || t_string!(i18n, free_mode_tooltip).to_owned()>"?"</span>
</label>
</div> </div>
// ── Pre-game ceremony overlay ───────────────────────────────────── // ── Pre-game ceremony overlay ─────────────────────────────────────

View file

@ -8,7 +8,7 @@ use rand::seq::IndexedRandom;
use std::cmp; use std::cmp;
use std::collections::HashSet; use std::collections::HashSet;
#[derive(std::cmp::PartialEq, Debug)] #[derive(std::cmp::PartialEq, Debug, Clone, Copy)]
pub enum MoveError { pub enum MoveError {
// Opponent corner is forbidden // Opponent corner is forbidden
OpponentCorner, OpponentCorner,

View file

@ -1,6 +1,6 @@
mod game; mod game;
mod game_rules_moves; mod game_rules_moves;
pub use game_rules_moves::MoveRules; pub use game_rules_moves::{MoveError, MoveRules};
mod game_rules_points; mod game_rules_points;
pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage}; pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage};
pub use game_rules_points::{Jan, PointsRules}; pub use game_rules_points::{Jan, PointsRules};