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]]
|
[[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",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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é"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 ─────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue