feat(web client): free play mode
This commit is contained in:
parent
f459021f22
commit
486649a599
10 changed files with 357 additions and 63 deletions
10
Cargo.lock
generated
10
Cargo.lock
generated
|
|
@ -189,7 +189,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "backbone-lib"
|
||||
version = "0.2.13"
|
||||
version = "0.2.15"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"ewebsock",
|
||||
|
|
@ -2658,7 +2658,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "protocol"
|
||||
version = "0.2.13"
|
||||
version = "0.2.15"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
|
@ -2911,7 +2911,7 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
|||
|
||||
[[package]]
|
||||
name = "relay-server"
|
||||
version = "0.2.13"
|
||||
version = "0.2.15"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"axum",
|
||||
|
|
@ -3921,7 +3921,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "trictrac-store"
|
||||
version = "0.2.13"
|
||||
version = "0.2.15"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64 0.21.7",
|
||||
|
|
@ -3934,7 +3934,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "trictrac-web"
|
||||
version = "0.2.13"
|
||||
version = "0.2.15"
|
||||
dependencies = [
|
||||
"backbone-lib",
|
||||
"futures",
|
||||
|
|
|
|||
|
|
@ -1855,6 +1855,58 @@ a:hover { text-decoration: underline; }
|
|||
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 ──────────────────────────────────────────── */
|
||||
.ceremony-overlay {
|
||||
position: fixed;
|
||||
|
|
|
|||
|
|
@ -149,5 +149,19 @@
|
|||
"delete_account_mismatch": "Username does not match.",
|
||||
"account_deleted": "Your account has been permanently deleted.",
|
||||
"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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -147,5 +147,19 @@
|
|||
"delete_account_mismatch": "Le nom d'utilisateur ne correspond pas.",
|
||||
"account_deleted": "Votre compte a été définitivement supprimé.",
|
||||
"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é"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -246,6 +246,86 @@ fn valid_dests_for(
|
|||
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]
|
||||
pub fn Board(
|
||||
view_state: ViewState,
|
||||
|
|
@ -275,8 +355,13 @@ pub fn Board(
|
|||
/// Suppress dice animation (echo screen shown after a pending confirm was dismissed).
|
||||
#[prop(default = false)]
|
||||
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 {
|
||||
let board = view_state.board;
|
||||
let vs_dice = view_state.dice;
|
||||
let white_points = view_state.scores[0].points;
|
||||
let white_can_bredouille = view_state.scores[0].can_bredouille;
|
||||
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) {
|
||||
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 {
|
||||
if let Some(origin) = sel {
|
||||
if origin == field_num {
|
||||
|
|
@ -430,40 +532,54 @@ pub fn Board(
|
|||
let staged = staged_moves.get_untracked();
|
||||
if staged.len() >= 2 { return; }
|
||||
|
||||
match selected_origin.get_untracked() {
|
||||
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)));
|
||||
if free_mode.get_untracked() {
|
||||
match selected_origin.get_untracked() {
|
||||
Some(origin) if 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));
|
||||
Some(origin) => {
|
||||
let dests = free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit);
|
||||
if dests.iter().any(|&d| d == field_num) {
|
||||
staged_moves.update(|v| v.push((origin, field_num)));
|
||||
selected_origin.set(None);
|
||||
}
|
||||
} 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) {
|
||||
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 {
|
||||
// selected_origin.set(Some(field_num));
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match selected_origin.get_untracked() {
|
||||
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,
|
||||
// because the first move can bring all checkers in (e.g. 15→21, 19→exit).
|
||||
let staged = staged_moves.get();
|
||||
let show = is_move_stage && match staged.len() {
|
||||
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || 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)
|
||||
let show = is_move_stage && if free_mode.get() {
|
||||
// In free mode show exit button whenever all checkers are in exit zone
|
||||
all_in_exit && staged.len() < 2
|
||||
} else {
|
||||
match staged.len() {
|
||||
0 => seqs_exit.iter().any(|(m1, m2)| m1.get_to() == 0 || 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(|| {
|
||||
let seqs_exit_cls = seqs_exit.clone();
|
||||
|
|
@ -657,10 +778,15 @@ pub fn Board(
|
|||
let staged = staged_moves.get();
|
||||
let sel = selected_origin.get();
|
||||
let active = match sel {
|
||||
Some(origin) => seqs_exit_cls.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_cls, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0),
|
||||
Some(origin) => if free_mode.get() {
|
||||
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
|
||||
.iter().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,
|
||||
};
|
||||
if active { "exit-btn exit-active" } else { "exit-btn" }
|
||||
|
|
@ -672,10 +798,15 @@ pub fn Board(
|
|||
let Some(origin) = selected_origin.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let valid = seqs_exit_click.is_empty()
|
||||
|| valid_dests_for(&seqs_exit_click, &staged, origin)
|
||||
.iter()
|
||||
.any(|&d| d == 0);
|
||||
let valid = if free_mode.get_untracked() {
|
||||
free_mode_dests_for(board, &staged, origin, vs_dice, is_white, all_in_exit)
|
||||
.iter().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 {
|
||||
staged_moves.update(|v| v.push((origin, 0)));
|
||||
selected_origin.set(None);
|
||||
|
|
|
|||
|
|
@ -2,8 +2,9 @@ use std::cell::Cell;
|
|||
use std::collections::VecDeque;
|
||||
|
||||
use futures::channel::mpsc::UnboundedSender;
|
||||
use gloo_storage::Storage as _;
|
||||
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 crate::app::{GameUiState, NetCommand, PauseReason};
|
||||
|
|
@ -20,6 +21,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
let i18n = use_i18n();
|
||||
|
||||
let vs = state.view_state.clone();
|
||||
let vs_board = vs.board;
|
||||
let vs_dice = vs.dice;
|
||||
let player_id = state.player_id;
|
||||
let is_my_turn = vs.active_mp_player == Some(player_id);
|
||||
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).
|
||||
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 |_| {
|
||||
let moves = staged_moves.get();
|
||||
let n = moves.len();
|
||||
|
|
@ -61,12 +77,36 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
let to_cm = |&(from, to): &(u8, u8)| {
|
||||
CheckerMove::new(from as usize, to as usize).unwrap_or_default()
|
||||
};
|
||||
cmd_tx_effect
|
||||
.unbounded_send(NetCommand::Action(PlayerAction::Move(
|
||||
to_cm(&moves[0]),
|
||||
to_cm(&moves[1]),
|
||||
)))
|
||||
.ok();
|
||||
let m1 = to_cm(&moves[0]);
|
||||
let m2 = to_cm(&moves[1]);
|
||||
|
||||
if free_mode.get_untracked() {
|
||||
// Mirror moves to White-perspective for validation (MoveRules always works as White)
|
||||
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![]);
|
||||
selected_origin.set(None);
|
||||
// Reset the counter so the next turn starts clean.
|
||||
|
|
@ -344,6 +384,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
last_moves=last_moves
|
||||
hit_fields=hit_fields
|
||||
suppress_dice_anim=suppress_dice_anim
|
||||
free_mode=free_mode
|
||||
/>
|
||||
|
||||
// ── 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> })
|
||||
}}
|
||||
// ── 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">
|
||||
{waiting_for_confirm.then(|| view! {
|
||||
<button class="btn btn-primary" on:click=move |_| {
|
||||
|
|
@ -436,6 +504,21 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
|
|||
})
|
||||
}}
|
||||
</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>
|
||||
|
||||
// ── Pre-game ceremony overlay ─────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ use rand::seq::IndexedRandom;
|
|||
use std::cmp;
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(std::cmp::PartialEq, Debug)]
|
||||
#[derive(std::cmp::PartialEq, Debug, Clone, Copy)]
|
||||
pub enum MoveError {
|
||||
// Opponent corner is forbidden
|
||||
OpponentCorner,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
mod game;
|
||||
mod game_rules_moves;
|
||||
pub use game_rules_moves::MoveRules;
|
||||
pub use game_rules_moves::{MoveError, MoveRules};
|
||||
mod game_rules_points;
|
||||
pub use game::{EndGameReason, GameEvent, GameState, Stage, TurnStage};
|
||||
pub use game_rules_points::{Jan, PointsRules};
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue