diff --git a/Cargo.lock b/Cargo.lock index 595791c..72f1a21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 5377337..52537ac 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace.package] -version = "0.2.15" +version = "0.2.16" [workspace] resolver = "2" diff --git a/README.md b/README.md index f9485c7..2094edb 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,6 @@ Open a browser window at `http://127.0.0.1:9091`. You can play against a very ba ## Inspirations -The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_, _server/relay-server_ is a Leptos-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. It is a multiplayer game system in Rust targeting browser-based board games compiled as WASM. The original project used Macroquad with a polling-based transport layer; this version replaces that with an async session API built for [Leptos](https://leptos.dev/). +The multiplayer game architecture, implemented in packages _clients/backbone-lib_, _clients/web/game_, _server/protocol_ and _server/relay-server_ is a [Leptos](https://leptos.dev/)-optimized adaptation of the macroquad-based [Carbonfreezer/multiplayer](https://github.com/Carbonfreezer/multiplayer) project. The web client UX/UI is inspired by https://playtiao.com. diff --git a/clients/web/assets/style.css b/clients/web/assets/style.css index 1d4cc77..86e7cb8 100644 --- a/clients/web/assets/style.css +++ b/clients/web/assets/style.css @@ -1291,7 +1291,7 @@ a:hover { text-decoration: underline; } pointer-events: auto; display: flex; flex-direction: column; - align-items: flex-end; + align-items: center; gap: 3px; animation: scoring-panel-enter 0.3s ease-out; } @@ -1855,6 +1855,56 @@ 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 { + text-align: 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 { + font-family: var(--font-ui); + font-size: 0.85rem; + color: #8b2000; + font-style: italic; +} + /* ── Pre-game ceremony overlay ──────────────────────────────────────────── */ .ceremony-overlay { position: fixed; @@ -2251,3 +2301,219 @@ a:hover { text-decoration: underline; } background: rgba(200,164,72,0.1); font-weight: 600; } + +/* Prevent horizontal scrollbar from the full-bleed strip */ +.game-overlay { overflow-x: hidden !important; } + +/* Board bar: hide die slots, keep the rail as a thin divider */ +.bar-die-slot { display: none !important; } +.board-bar { width: 5px; overflow: hidden; } + +/* ── Full-width in-flow player strip ─────────────────────────────────── */ +.players-strip { + width: 100vw; + margin-top: -1.5rem; /* undo game-overlay top padding */ + display: flex; + align-items: center; + background: var(--ui-parchment); + border-bottom: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + padding: 0.35rem 1.5rem; + gap: 0.5rem; +} + +.strip-player { display: flex; align-items: center; flex: 1; min-width: 0; } +.strip-player-left { justify-content: flex-end; } +.strip-player-right { justify-content: flex-start; } + +.strip-active-zone { + display: flex; + align-items: center; + gap: 0.7rem; + border-radius: 8px; + padding: 0.28rem 0.5rem; + transition: background 0.15s; +} +.strip-active-zone.active { background: rgba(58,42,10,0.15); } + +/* Checker-style circles */ +.strip-avatar { + width: 38px; height: 38px; + border-radius: 50%; + flex-shrink: 0; +} +.strip-avatar-me { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15); +} +.strip-avatar-opp { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4); +} + +/* Strip peg overrides */ +.players-strip .peg-track { gap: 3px; } +.players-strip .peg-hole { width: 12px; height: 12px; } +.players-strip .peg-hole.filled { + background: #5aab38; border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} +.players-strip .peg-hole.peg-opp.filled { + background: #c05030; border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Strip score-row-name: remove fixed width from v01 */ +.players-strip .score-row-name { width: auto; } + +/* No ghost bar below pts-counter in the strip */ +.players-strip .pts-counter-wrap { padding-bottom: 0; } + +/* Center "Trictrac" title */ +.players-strip-center { + flex-shrink: 0; + padding: 0 1rem; + border-left: 1px solid rgba(138,106,40,0.2); + border-right: 1px solid rgba(138,106,40,0.2); +} +.strip-title { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.03em; + white-space: nowrap; + margin-left: 1rem; +} + +/* ── Body: board + controls ──────────────────────────────────────────── */ +.main-body { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ +.controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + align-self: stretch; +} +@media (min-width: 920px) { + .controls { + width: 200px; + } +} + +.ctrl-dice { + background: var(--board-rail); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.ctrl-dice-row { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; +} + +/* Free-mode toggle: light text on dark board-rail background */ +.ctrl-dice .free-mode-toggle { + color: var(--ui-parchment); + font-size: 0.7rem; + flex-wrap: wrap; + justify-content: center; + text-align: center; + gap: 0.3rem; +} +.ctrl-dice .free-mode-help { + border-color: rgba(242,232,208,0.35); + color: rgba(242,232,208,0.5); +} + +.ctrl-status { + background: var(--ui-parchment); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.65rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} +.ctrl-status .game-status { + color: var(--ui-ink); + text-shadow: none; + font-size: 1rem; + padding: 0; + width: auto; + text-align: center; + line-height: 1.3; +} +.ctrl-status .board-actions { + flex-wrap: wrap; + justify-content: center; + min-height: 0; +} +.ctrl-status .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; + text-align: center; + font-size: 0.67rem; + line-height: 1.4; + margin: 0; +} + +.scoring-row .scoring-panels-container { + position: static; + top: auto; left: auto; right: auto; bottom: auto; + z-index: auto; + padding: 0; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.scoring-row .scoring-panel { + box-sizing: border-box; + margin: 0; +} + +/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ +@media (max-width: 919px) { + .main-body { + flex-direction: column; + align-items: stretch; + } + .controls { + flex-direction: row; + width: 100%; + } + .ctrl-status { flex: 1; } + /* Hide pegs on small screens to save space in the strip */ + .players-strip .peg-track { display: none; } +} diff --git a/clients/web/locales/en.json b/clients/web/locales/en.json index 1e5fbc2..ebc5130 100644 --- a/clients/web/locales/en.json +++ b/clients/web/locales/en.json @@ -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" } diff --git a/clients/web/locales/fr.json b/clients/web/locales/fr.json index 28ae43c..5889556 100644 --- a/clients/web/locales/fr.json +++ b/clients/web/locales/fr.json @@ -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é" } diff --git a/clients/web/pages/legal/en.md b/clients/web/pages/legal/en.md index ff72761..8f890f2 100644 --- a/clients/web/pages/legal/en.md +++ b/clients/web/pages/legal/en.md @@ -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. diff --git a/clients/web/pages/legal/fr.md b/clients/web/pages/legal/fr.md index 43f85d5..442aac4 100644 --- a/clients/web/pages/legal/fr.md +++ b/clients/web/pages/legal/fr.md @@ -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. diff --git a/clients/web/src/game/components/board.rs b/clients/web/src/game/components/board.rs index dda9ddc..c1a12c6 100644 --- a/clients/web/src/game/components/board.rs +++ b/clients/web/src/game/components/board.rs @@ -39,7 +39,7 @@ fn field_zone_class(field_num: u8) -> &'static str { } /// Returns (d0_used, d1_used) for the bar dice display. -fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { +pub(crate) fn bar_matched_dice_used(staged: &[(u8, u8)], dice: (u8, u8)) -> (bool, bool) { let mut d0 = false; let mut d1 = false; for &(from, to) in staged { @@ -112,7 +112,8 @@ fn valid_origins_for(seqs: &[(CheckerMove, CheckerMove)], staged: &[(u8, u8)]) - } /// Pixel center of a board field in the SVG overlay coordinate space. -/// Geometry: field 60×180px, board padding 4px, gap 4px, bar 20px, center-bar 12px. +/// Geometry: field 60×180px, board padding 4px, row gap 4px, bar 5px, center-bar 12px. +/// Quarter width: 6×60 + 5×2(inter-field gap) = 370px. Board total: 761px. /// With triangular flèches, arrows target the WIDE BASE of each triangle — /// that is where the checker stack actually sits. fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { @@ -137,9 +138,9 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> { } }; // Left-quarter field i center x: 4(pad) + i*62 + 30(half field) = 34 + 62i - // Right-quarter: 4 + 370(quarter) + 4(gap) + 68(bar) + 4(gap) + i*62 + 30 = 480 + 62i + // Right-quarter: 4 + 370(quarter) + 4(gap) + 5(bar) + 4(gap) + i*62 + 30 = 417 + 62i let x = if right { - 480.0 + qi as f32 * 62.0 + 417.0 + qi as f32 * 62.0 } else { 34.0 + qi as f32 * 62.0 }; @@ -246,6 +247,107 @@ 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 { + (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 { + let to_use: Vec = 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) { + if d == 13 && is_white && displayed_value(board, staged, is_white, 12) < 2 { + // prise de coin par puissance for white + dests.push(12) + } else if d == 12 && !is_white && displayed_value(board, staged, is_white, 13) > -2 + { + // prise de coin par puissance for black + dests.push(13) + } else { + 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 +377,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, ) -> 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 +496,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 +554,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)); + } } } } @@ -560,23 +698,11 @@ pub fn Board( (&TOP_LEFT_B, &TOP_RIGHT_B, &BOT_LEFT_B, &BOT_RIGHT_B) }; - // Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective. - let (label_tl, label_tr, label_bl, label_br) = if is_white { - ("", "jan de retour", "grand jan", "petit jan") - } else { - ("petit jan", "grand jan", "jan de retour", "") - }; - view! { // board-wrapper keeps zone labels outside .board so the SVG overlay // inside .board stays correctly positioned (position:absolute top:0 left:0 // is relative to .board, not the wrapper).
-
-
{label_tl}
-
-
{label_tr}
-
{fields_from(tl, true)}
@@ -591,7 +717,7 @@ pub fn Board(
// SVG overlay: arrows for hovered jan moves {move || { @@ -624,15 +750,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 +788,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 +808,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); @@ -702,11 +843,6 @@ pub fn Board( }) }}
-
-
{label_bl}
-
-
{label_br}
-
} } diff --git a/clients/web/src/game/components/game_screen.rs b/clients/web/src/game/components/game_screen.rs index 4e1ce38..46f9deb 100644 --- a/clients/web/src/game/components/game_screen.rs +++ b/clients/web/src/game/components/game_screen.rs @@ -2,16 +2,19 @@ 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::board::{bar_matched_dice_used, Board}; use super::die::Die; use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::game::trictrac::types::{PlayerAction, PreGameRollState, SerStage, SerTurnStage}; use crate::i18n::*; use crate::portal::lobby::{qr_svg, room_url}; -use super::board::Board; use super::score_panel::MergedScorePanel; use super::scoring::ScoringPanel; @@ -20,6 +23,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 @@ -44,15 +49,21 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let pending = use_context::>>().expect("pending not found in context"); let cmd_tx_effect = cmd_tx.clone(); - // Non-reactive counter so we can detect when staged_moves grows without - // returning a value from the Effect (which causes a Leptos reactive loop - // when the Effect also writes to the same signal it reads). let prev_staged_len = Cell::new(0usize); + // ── Free-play mode ───────────────────────────────────────────────────────── + fn load_free_mode() -> bool { + gloo_storage::LocalStorage::get::("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 = RwSignal::new(load_free_mode()); + let move_error: RwSignal>> = RwSignal::new(None); + Effect::new(move |_| { let moves = staged_moves.get(); let n = moves.len(); - // Play checker sound whenever a move is added (own moves, immediate feedback). if n > prev_staged_len.get() { crate::game::sound::play_checker_move(); } @@ -61,28 +72,48 @@ 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(); - staged_moves.set(vec![]); - selected_origin.set(None); - // Reset the counter so the next turn starts clean. - prev_staged_len.set(0); + let m1 = to_cm(&moves[0]); + let m2 = to_cm(&moves[1]); + + if free_mode.get_untracked() { + 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(); + staged_moves.set(vec![]); + selected_origin.set(None); + prev_staged_len.set(0); + } else { + let specific_err = rules.moves_allowed(&(vm1, vm2)).err(); + move_error.set(Some(specific_err)); + // Keep staged_moves intact so pieces stay in place until Retry is clicked. + } + } else { + cmd_tx_effect + .unbounded_send(NetCommand::Action(PlayerAction::Move(m1, m2))) + .ok(); + staged_moves.set(vec![]); + selected_origin.set(None); + prev_staged_len.set(0); + } } }); // ── Auto-roll effect ───────────────────────────────────────────────────── - // GameScreen is fully re-mounted on every ViewState update (state is a - // plain prop, not a signal), so this effect fires exactly once per - // RollDice phase entry and will not double-send. - // Guard: suppressed while waiting_for_confirm — the AfterOpponentMove - // buffered state shows the human's RollDice turn but the auto-roll must - // wait until the buffer is drained and the live screen state is shown. - // Guard: never auto-roll during the pre-game ceremony (the ceremony overlay - // has its own Roll button for PlayerAction::PreGameRoll). let show_roll = is_my_turn && vs.turn_stage == SerTurnStage::RollDice && vs.stage != SerStage::PreGameRoll; if show_roll && !waiting_for_confirm { @@ -101,14 +132,11 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let cmd_tx_go = cmd_tx.clone(); let cmd_tx_end_quit = cmd_tx.clone(); let cmd_tx_end_replay = cmd_tx.clone(); - // Only show the fallback Go button when there is no ScoringPanel showing it. let show_hold_go = is_my_turn && vs.turn_stage == SerTurnStage::HoldOrGoChoice && state.my_scored_event.is_none(); // ── Valid move sequences for this turn ───────────────────────────────────── - // Computed once per ViewState snapshot; used by Board (highlighting) and the - // empty-move button (visibility). let valid_sequences: Vec<(CheckerMove, CheckerMove)> = if is_move_stage && dice != (0, 0) { let mut store_board = StoreBoard::new(); store_board.set_positions(&Color::White, vs.board); @@ -130,14 +158,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } else { vec![] }; - // Clone for the empty-move button reactive closure (Board consumes the original). let valid_seqs_empty = valid_sequences.clone(); // ── Scores ───────────────────────────────────────────────────────────────── let my_score = vs.scores[player_id as usize].clone(); let opp_score = vs.scores[1 - player_id as usize].clone(); - // ── Ceremony state (extracted before vs is moved into Board) ──────────────── + // ── Ceremony state ────────────────────────────────────────────────────────── let is_ceremony = vs.stage == SerStage::PreGameRoll; let pre_game_roll_data: Option = vs.pre_game_roll.clone(); let my_name_ceremony = my_score.name.clone(); @@ -148,8 +175,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let my_scored_event = state.my_scored_event.clone(); let opp_scored_event = state.opp_scored_event.clone(); - // Values for MergedScorePanel — extracted before events are consumed. - // Don't animate points when a hole was gained (points wrap around 12). let my_pts_earned: u8 = my_scored_event.as_ref().map_or(0, |e| { if e.holes_gained == 0 { e.points_earned @@ -174,7 +199,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let last_moves = state.last_moves; - // fields where a battue (hit) was scored; ripple animation shown there. let hit_fields: Vec = { let is_hit_jan = |jan: &Jan| { matches!( @@ -206,10 +230,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { fields }; - // ── Sound effects (fire once on mount = once per state snapshot) ────────── - // Dice roll: dice are fresh for the currently active player (Move stage means - // someone just rolled). Skipped on turn-switch states where the old dice linger - // in RollDice/MarkPoints stage before the opponent has rolled. + // ── Sound effects ────────────────────────────────────────────────────────── let active_is_move_stage = matches!( vs.turn_stage, SerTurnStage::Move | SerTurnStage::HoldOrGoChoice @@ -217,12 +238,9 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { if show_dice && last_moves.is_none() && active_is_move_stage && !suppress_dice_anim { crate::game::sound::play_dice_roll(); } - // Checker move: moves were committed in the preceding action. if last_moves.is_some() { crate::game::sound::play_checker_move(); } - // Scoring: hole fanfare plays immediately; per-point ticks are driven by - // MergedScorePanel's counter animation so play_points_scored is not called here. if let Some(ref ev) = my_scored_event { if ev.holes_gained > 0 { crate::game::sound::play_hole_scored(); @@ -242,6 +260,13 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { let room_id = state.room_id.clone(); let is_bot_game = state.is_bot_game; + // ── Active player indicator ──────────────────────────────────────────────── + let active_player_is_me: Option = if stage == SerStage::InGame { + Some(is_my_turn) + } else { + None + }; + // ── Game-over info ───────────────────────────────────────────────────────── let stage_is_ended = stage == SerStage::Ended; let winner_is_me = my_score.holes >= 12; @@ -263,8 +288,8 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { }; view! { - // ── Game container ────────────────────────────────────────────────────
+ // ── Share popover (while waiting for opponent) ─────────────────── {(!is_bot_game && stage == SerStage::PreGame).then(|| { let url_label = share_url.clone(); @@ -306,20 +331,201 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView { } })} - // ── Merged scoreboard + scoring panels ───────────── - // score-area is position:relative so the scoring-panels-container - // can be absolute-positioned at the right of the hole counter. -
- + + // ── Board + controls (sidebar on wide, footer on narrow) ───────── +
+ - // Scoring detail panels — stacked at the right, overlapping if needed. + + // ── Controls: dice card + status/actions card ──────────────── +
+ {show_dice.then(|| view! { +
+
+ {move || { + let staged = staged_moves.get(); + let (u0, u1) = if suppress_dice_anim { + (true, true) + } else if is_move_stage { + bar_matched_dice_used(&staged, dice) + } else { + (false, false) + }; + view! { + + + } + }} +
+ +
+ })} + +
+
+ {move || { + if let Some(ref reason) = pause_reason { + return String::from(match reason { + PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), + PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), + PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), + PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), + }); + } + let n = staged_moves.get().len(); + if is_move_stage { + t_string!(i18n, select_move, n = n + 1) + } else { + String::from(match (&stage, is_my_turn, &turn_stage) { + (SerStage::Ended, _, _) => t_string!(i18n, game_over), + (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), + (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), + (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), + (SerStage::InGame, true, _) => t_string!(i18n, your_turn), + (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), + }) + } + }} +
+ {move || { + let hint: String = if waiting_for_confirm { + t_string!(i18n, hint_continue).to_owned() + } else if is_move_stage { + t_string!(i18n, hint_move).to_owned() + } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { + t_string!(i18n, hint_hold_or_go).to_owned() + } else { + String::new() + }; + (!hint.is_empty()).then(|| view! {

{hint}

}) + }} + // ── 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! { +
+ {msg} + +
+ } + }) + }} +
+ {waiting_for_confirm.then(|| view! { + + })} + {show_hold_go.then(|| view! { + + })} + {move || { + let staged = staged_moves.get(); + let show = is_move_stage && staged.len() < 2 && ( + valid_seqs_empty.is_empty() || match staged.len() { + 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), + 1 => { + let (f0, t0) = staged[0]; + valid_seqs_empty.iter() + .filter(|(m1, _)| { + m1.get_from() as u8 == f0 + && m1.get_to() as u8 == t0 + }) + .any(|(_, m2)| m2.get_from() == 0) + } + _ => false, + } + ); + show.then(|| view! { + + }) + }} + {move || { + (is_move_stage && staged_moves.get().len() == 1).then(|| view! { + + }) + }} +
+
+
+
+ + // ── Scoring notification panels ─────────────────────────────────── +
{my_scored_event.map(|event| view! { @@ -330,114 +536,6 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
- // ── Board ──────────────────────────────────────────────────────── - - - // ── Status, hints, and actions — cream strip below board ─ -
-
- {move || { - if let Some(ref reason) = pause_reason { - return String::from(match reason { - PauseReason::AfterOpponentRoll => t_string!(i18n, after_opponent_roll), - PauseReason::AfterOpponentGo => t_string!(i18n, after_opponent_go), - PauseReason::AfterOpponentMove => t_string!(i18n, after_opponent_move), - PauseReason::AfterOpponentPreGameRoll => t_string!(i18n, after_opponent_pre_game_roll), - }); - } - let n = staged_moves.get().len(); - if is_move_stage { - t_string!(i18n, select_move, n = n + 1) - } else { - String::from(match (&stage, is_my_turn, &turn_stage) { - (SerStage::Ended, _, _) => t_string!(i18n, game_over), - (SerStage::PreGame, _, _) | (SerStage::PreGameRoll, _, _) => t_string!(i18n, waiting_for_opponent), - (SerStage::InGame, true, SerTurnStage::RollDice) => t_string!(i18n, your_turn_roll), - (SerStage::InGame, true, SerTurnStage::HoldOrGoChoice) => t_string!(i18n, hold_or_go), - (SerStage::InGame, true, _) => t_string!(i18n, your_turn), - (SerStage::InGame, false, _) => t_string!(i18n, opponent_turn), - }) - } - }} -
- {move || { - let hint: String = if waiting_for_confirm { - t_string!(i18n, hint_continue).to_owned() - } else if is_move_stage { - t_string!(i18n, hint_move).to_owned() - } else if is_my_turn && turn_stage_for_sub == SerTurnStage::HoldOrGoChoice { - t_string!(i18n, hint_hold_or_go).to_owned() - } else { - String::new() - }; - (!hint.is_empty()).then(|| view! {

{hint}

}) - }} -
- {waiting_for_confirm.then(|| view! { - - })} - // Fallback Go button when no scoring panel (e.g. after reconnect) - {show_hold_go.then(|| view! { - - })} - {move || { - let staged = staged_moves.get(); - let show = is_move_stage && staged.len() < 2 && ( - valid_seqs_empty.is_empty() || match staged.len() { - 0 => valid_seqs_empty.iter().any(|(m1, _)| m1.get_from() == 0), - 1 => { - let (f0, t0) = staged[0]; - valid_seqs_empty.iter() - .filter(|(m1, _)| { - m1.get_from() as u8 == f0 - && m1.get_to() as u8 == t0 - }) - .any(|(_, m2)| m2.get_from() == 0) - } - _ => false, - } - ); - show.then(|| view! { - - }) - }} - {move || { - (is_move_stage && staged_moves.get().len() == 1).then(|| view! { - - }) - }} -
-
- // ── Pre-game ceremony overlay ───────────────────────────────────── {is_ceremony.then(|| { let pgr = pre_game_roll_data.unwrap_or(PreGameRollState { diff --git a/clients/web/src/game/components/score_panel.rs b/clients/web/src/game/components/score_panel.rs index bd531f3..94e5f8a 100644 --- a/clients/web/src/game/components/score_panel.rs +++ b/clients/web/src/game/components/score_panel.rs @@ -32,19 +32,18 @@ pub fn jan_label(jan: &Jan) -> String { } } -/// Merged scoreboard showing both players above the board. +/// Full-width player strip at the top of the game screen. /// -/// - Two stacked rows for a clear race-to-12 visual comparison. -/// - Points shown as an animated jackpot counter (ticks up on each new point). -/// - Hole pegs are larger and use green (me) / red (opponent) instead of gold. -/// - When a hole is gained, the new peg pops in and a brief non-blocking label -/// appears instead of the old blocking toast popup. +/// - Left side: me (right-aligned toward center): avatar → name → pegs → pts. +/// - Center: "Trictrac" italic title. +/// - Right side: opponent (left-aligned from center): pts → pegs → name → avatar. +/// - Active player zone gets a subtle rounded highlight. +/// - Points animate as a jackpot counter; new peg pops in with an animation. #[component] pub fn MergedScorePanel( my_score: PlayerScore, opp_score: PlayerScore, - /// Points just earned this turn; 0 = no animation. Set to 0 when a hole - /// was gained (points wrap around 12, counter stays at end value). + /// Points just earned this turn; 0 = no animation. #[prop(default = 0)] my_points_earned: u8, #[prop(default = 0)] opp_points_earned: u8, @@ -55,14 +54,13 @@ pub fn MergedScorePanel( /// True when my hole was scored under bredouille (shows ×2 in the flash). #[prop(default = false)] my_bredouille: bool, + /// `Some(true)` = my turn active, `Some(false)` = opponent active, `None` = no active turn. + #[prop(default = None)] + active_player_is_me: Option, ) -> impl IntoView { let i18n = use_i18n(); // ── Points counter signals ────────────────────────────────────────────── - // When no hole was gained: start from (current - earned) and tick up. - // When a hole was gained: points wrapped around 12, so skip the animation. - // On non-WASM there is no animation; start directly at the final value. - // Suppress the unused-variable warning for animation-only params. #[cfg(not(target_arch = "wasm32"))] let _ = (my_points_earned, opp_points_earned); #[cfg(not(target_arch = "wasm32"))] @@ -122,10 +120,6 @@ pub fn MergedScorePanel( } } - // ── Ghost bar widths (show the end value immediately — static reference) ─ - let my_bar_style = format!("width:{}%", (my_score.points as u32 * 100 / 12).min(100)); - let opp_bar_style = format!("width:{}%", (opp_score.points as u32 * 100 / 12).min(100)); - // ── Hole peg tracks ───────────────────────────────────────────────────── let my_holes = my_score.holes; let opp_holes = opp_score.holes; @@ -163,73 +157,77 @@ pub fn MergedScorePanel( let my_can_bredouille = my_score.can_bredouille; let opp_can_bredouille = opp_score.can_bredouille; + let my_active = active_player_is_me == Some(true); + let opp_active = active_player_is_me == Some(false); + view! { -
+
- // ── My player row ─────────────────────────────────────────── -
-
- {my_name} - {t!(i18n, you_suffix)} -
-
-
-
+ // ── My player: left side, right-aligned toward center ─────────── +
+
+
+
+ {my_name} + {t!(i18n, you_suffix)}
-
- {move || my_displayed_pts.get()} - "/12" -
-
-
{my_pegs}
- {my_can_bredouille.then(|| view! { - - "B" - - })} - // Flash sits in the free space to the right of the pegs. - // margin-left:auto keeps it right-aligned inside the flex row - // without adding a new row, so the board never shifts down. - {(my_holes_gained > 0).then(|| { - let label = if my_bredouille { - format!("Trou {} · ×2 bredouille", my_holes) - } else { - format!("Trou {}", my_holes) - }; - view! { -
- {label} + {my_can_bredouille.then(|| view! { + + "B" + + })} +
{my_pegs}
+
+
+ {move || my_displayed_pts.get()} + "/12"
- } - })} +
+ {(my_holes_gained > 0).then(|| { + let label = if my_bredouille { + format!("Trou {} · ×2 bredouille", my_holes) + } else { + format!("Trou {}", my_holes) + }; + view! { +
+ {label} +
+ } + })} +
-
- - // ── Opponent row ──────────────────────────────────────────── -
-
- {opp_name} -
-
-
-
-
-
- {move || opp_displayed_pts.get()} - "/12" -
-
-
{opp_pegs}
- {opp_can_bredouille.then(|| view! { - - "B" - - })} + // ── Center title ──────────────────────────────────────────────── +
+ "Trictrac"
+ + // ── Opponent: right side, left-aligned from center ────────────── +
+
+
+
+ {move || opp_displayed_pts.get()} + "/12" +
+
+
{opp_pegs}
+ {opp_can_bredouille.then(|| view! { + + "B" + + })} +
+ {opp_name} +
+
+
+
+
} } diff --git a/doc/client_web_design_proposals.md b/doc/client_web_design_proposals.md deleted file mode 100644 index 598e9c5..0000000 --- a/doc/client_web_design_proposals.md +++ /dev/null @@ -1,307 +0,0 @@ -# client_web — UI/UX Design Proposals - -A structured critique of the current interface compared to the physical game, followed by concrete upgrade proposals. Organised from most impactful to most effort-intensive. - ---- - -## Aesthetic Direction - -**Concept: "18th-century French gaming salon"** - -The physical trictrac board is a piece of furniture — carved mahogany rails, felt or baize surface, ivory and ebony checkers, brass pegs in drilled holes, gilt scoring tokens. The online interface should feel like playing on that table under candlelight in a Parisian salon. - -- **Typography**: Pair a classical serif (e.g. [Cormorant Garamond](https://fonts.google.com/specimen/Cormorant+Garamond)) for headings and score readouts with a clean humanist sans (e.g. [Jost](https://fonts.google.com/specimen/Jost)) for UI controls and status text. -- **Palette**: Forest green felt board (`#1d3d28`), alternating ivory (`#f0e6c8`) and deep burgundy (`#7a1e2a`) triangular fields, dark mahogany rails (`#2a1508`), aged parchment panels (`#f2e8d0`), gilt gold accents (`#c8a448`). -- **Unforgettable detail**: Triangular fields (true *flèches*) rendered in CSS with a wood-grain body surrounding them. - ---- - -## 1. Board Shape: Rectangles → True Triangles - -**Current state**: Fields are 60×180px rectangles with a rounded corner. No backgammon/trictrac board looks like this. - -**Physical game**: Fields are elongated triangles (*flèches*) pointing from the rail toward the center bar, alternating two colors. - -**Proposal**: Replace `.field` `
` elements with SVG triangles, or use CSS `clip-path: polygon(50% 0%, 0% 100%, 100% 100%)` for bottom-row triangles and `clip-path: polygon(0% 0%, 100% 0%, 50% 100%)` for top-row triangles. The field background and checker stack become SVG foreignObject or positioned elements inside. This is a large structural change to `board.rs` but is the single highest-impact visual improvement. - -The board body between triangles becomes visible as the wood/felt surface — this naturally creates the physical board's "relief" without any extra decoration. - ---- - -## 2. Jan Zone Visual Identity - -**Current state**: The four quarters (small jan, big jan, return jan, last jan) are visually identical — same field color scheme, no labels or separation beyond the center bar. - -**Physical game**: Players must constantly know which quarter they are in because the rules differ radically per zone (forbidden jans, filling values, hit scoring values differ between small-jan-table and big-jan-table, corner position, exit zone). - -**Proposals**: - -### 2a. Zone labels -Add thin labels (`"petit jan"`, `"grand jan"`, `"jan de retour"`) beneath the board-row (or as a subtle strip above/below the quarter). These should use the serif font at very small size and low opacity — decorative, not noisy. - -### 2b. Field color shift per zone -The physical game uses alternating colors within each quarter, but different quarters can use slightly different base hues: -- Small jan (fields 1–6): warm ivory / burgundy -- Big jan / corner zone (fields 7–12): same, but field 12 gets a distinct "corner" treatment (see §4) -- Return jan (fields 13–18): very subtly cooler ivory / dark teal instead of burgundy — signals "opponent's territory" -- Last jan / exit (fields 19–24): subtly warmer, indicating checkers are "almost home" - -### 2c. Small-jan-table / big-jan-table highlight during hit scoring -When a hit is being scored, briefly tint the entire table (fields 1–12 or 13–24) to make the point value distinction (4 pts vs 2 pts) spatially obvious. This fires as a 300ms flash synchronized with the scoring notification. - ---- - -## 3. Rest Corner (Field 12 / 13) Special Appearance - -**Current state**: Field 12 looks identical to field 11. Nothing indicates its unique rules (must enter/leave with 2 checkers simultaneously, cannot be landed on by a single checker). - -**Physical game**: The corner is a corner — it is literally in the corner of the table, a distinct physical location. - -**Proposals**: -- Give field 12 (and 13 for Black) a **crown or arch shape** at the tip of the triangle, using a small SVG ornament. -- Apply a **slightly warmer gold** field color to distinguish it. -- When the player has two checkers there, show a subtle **lock icon** or a gilded ring around the checker stack to indicate "corner held." -- When the corner is available to be taken *par puissance*, add a gentle pulsing outline on field 12 to indicate the privilege is available. -- Tooltip or popover: on hover, show a brief note "Coin de repos — must enter and leave with 2 checkers." - ---- - -## 4. Checker Rendering: Static → Animated - -**Current state**: Checkers appear and disappear between `ViewState` snapshots. No movement animation. - -**Physical game**: Checkers slide across the board with a satisfying click sound. - -**Proposals**: - -### 4a. Slide animation -Diff the board array between the previous and current `ViewState`. For each checker that moved from field A to field B, apply a CSS or Web Animation API translation from `field_center(A)` to `field_center(B)` (duration ~250ms, ease-out). This requires keeping the previous `ViewState` as state in `GameScreen` and computing a diff when a new state arrives. - -### 4b. Lift effect during staging -When the player clicks an origin field and a checker becomes "selected," apply a `transform: scale(1.15) translateY(-4px)` with a subtle drop shadow increase. Visually lifts the checker off the board. - -### 4c. Checker appearance -Replace the CSS `radial-gradient` circles with SVG: -- **White**: ivory `#f5edd8` with a pearl sheen gradient, thin gilt ring border, engraved concentric circles -- **Black**: ebony `#1a0f06` with subtle grain texture, same gilt ring - -A stack of 5+ checkers can render a "perspective stack" — each checker at a slight y offset with a shadow, giving depth. - ---- - -## 5. Dice: Static → Rolling Animation - -**Current state**: Dice appear with their final value immediately. No sense of randomness or anticipation. - -**Physical game**: Dice are shaken in a cup (*cornet*) and tumbled out. The roll is a theatrical moment. - -**Proposals**: - -### 5a. Roll animation -When `SerTurnStage` transitions from `RollDice` to `Move`, animate both dice with a fast face-cycling (showing random faces for ~400ms, decelerating to final value). Pure CSS `animation` on the die-face SVG circles, cycling via `keyframes`. - -### 5b. Dice cups -Add two SVG/CSS dice cups above the dice display. During rolling, they visually "tip" (rotate 90° via CSS transform) and the dice "fall out." A subtle translate-y on the dice moves them downward into view. - -### 5c. Double visual -When both dice show the same value, add a subtle golden glow around both — visually communicating that it is a double (which affects scoring: 6 pts instead of 4, etc.). - -### 5d. Used-die visual -When one die has been consumed by a staged move, slide it slightly down and reduce opacity (current: gray-out). Animate the "used" transition with `transition: all 0.15s`. - ---- - -## 6. Scoring Notifications: Side Panel → Layered Toasts - -**Current state**: Scoring events appear as a small cream panel in the side panel column (`scoring-panel`). They are easily missed, especially opponent events. - -**Physical game**: Scoring is the central drama of every turn — points are loudly marked, bredouille doubled, holes recorded with pegs. - -**Proposals**: - -### 6a. Board-overlaid toast for holes -When a hole is won, display a large centered overlay on the board — not a modal, but a translucent toast with gilt border: `"Trou ! ×2 bredouille"`. Auto-dismiss after 1.5s or on click. This is the most important event and deserves the most visual weight. - -### 6b. Scoring event animation -When `my_scored_event` appears, animate the panel sliding in from the right with a 200ms ease-out. Jan rows stagger in (each with `animation-delay: n * 50ms`). - -### 6c. Jan hover → board highlight synchronization -The current arrow-on-hover feature is good. Extend it: when hovering a jan row, also highlight the relevant fields with a faint golden shimmer instead of (or in addition to) the arrows. This ties the abstract jan name to a concrete board location. - -### 6d. Bredouille treatment -Bredouille doubles a hole's value — a massive game event. Currently shown as a small amber badge. Proposals: -- The toast for a bredouille hole should animate in differently: bigger, gold shimmer background -- Show a small animated flag (*pavillon*) icon in the score panel when bredouille is active, matching the physical game's token - -### 6e. Hit scoring visual -When a hit is scored (*battue*), show a brief visual on the opponent's half-field checker — a faint concentric ring expanding outward (CSS `animation: ripple 0.4s ease-out`). This communicates the "fictitious" nature of the hit: something happened at that checker's position, but it didn't move. - ---- - -## 7. Score Panel: Progress Bars → Pegs and Holes - -**Current state**: Points and holes are displayed as progress bars (0–12) and numeric values. Functional but abstract. - -**Physical game**: Points are tracked with physical tokens (*jetons*) placed on the board surface at specific field tips. Holes are tracked with pegs (*fichets*) in holes drilled along the rail at each field base. - -**Proposals**: - -### 7a. Hole tracker: 12 dots/pegs -Replace the `score-bar-holes` progress bar with a row of 12 small circles ("drilled holes") in a horizontal strip. Filled holes are rendered as a gilded peg inserted (solid gold circle). Unfilled holes are empty rings. This is a `` with 12 `` elements. The filled count animates one peg at a time (sequenced `animation-delay`). - -### 7b. Point tracker: token on board -For points (0–11), show a small token image positioned at the corresponding field tip along the near rail — mirroring the physical game exactly. This is ambitious but highly authentic. A simpler approach: replace the thin progress bar with a 12-cell dot track where one glowing token is positioned. - -### 7c. Bredouille indicator -When `can_bredouille` is true for a player, show the token as a double-token (two stacked icons) or add a small flag icon next to the token. - ---- - -## 8. Status Communication: Text → Contextual Guidance - -**Current state**: A single text line (`"Select move 1"`, `"Opponent's turn"`) in the status bar. New players have no idea what to do or why they can't do something. - -**Physical game**: Human players narrate what's happening; experienced players understand the state from context. - -**Proposals**: - -### 8a. Contextual sub-prompt -Below the primary status, show a secondary hint line in smaller text: -- During `Move` stage: `"Click a highlighted field to move a checker"` -- During `HoldOrGoChoice`: `"Hold to keep points and keep playing — Go to reset and start a new setting"` -- When waiting for confirm: `"↑ Opponent scored points — click Continue when ready"` - -### 8b. Forbidden-jan visual cue -When a field is in the opponent's jan and the player cannot land there (forbidden jan rule), show those fields with a subtle `✕` pattern or darker tint rather than just being unclickable. This communicates *why* the fields aren't selectable. - -### 8c. Exit-eligible highlight -When all player checkers are in the last jan (fields 19–24), add a subtle directional glow to the exit rail (the right/left edge of the board depending on player). A small "EXIT →" arrow indicator could appear. - -### 8d. Can-take-corner indicator -When the player can take their corner (field 12 or 13 is the valid destination), add a brief pulse to that field beyond the standard `.dest` highlight — the corner rules are special enough to warrant extra visual salience. - ---- - -## 9. Bug Fix: Hold Button Is Non-Functional - -**File**: `src/components/scoring.rs` line 91 - -The "Hold" button in the `ScoringPanel` has no `on:click` handler. In the physical game, "Hold" (*tenir*) means: stay in the current setting, mark remainder points, and continue playing normally. - -`PlayerAction` does not currently include a `Hold` variant. In the current implementation, if the player simply does nothing (doesn't click Go), the game waits — but there is no message sent to the backend to confirm "staying." - -**Fix required**: Add `PlayerAction::Hold` (or reuse `Mark`) and connect the Hold button's `on:click` to send it. The backend needs to handle it by advancing past `HoldOrGoChoice` without triggering `GameEvent::Go`. - ---- - -## 10. Layout: Side Panel → Integrated Design - -**Current state**: The board and side panel sit side-by-side (`board-and-panel: flex-direction row`). The side panel (min-width 160px) contains status, dice, scoring, and buttons stacked vertically. - -**Proposals**: - -### 10a. Move dice inside the board -Place the dice display centered in the **board-bar** (the vertical divider between quarters). Currently the bar is 20px wide — widen it to ~80px and center two dice there. This puts dice physically near the board action, matching the physical game where dice land on the board surface. The bar color becomes a darker felt strip. - -### 10b. Status bar above the board -Move the primary status message to a full-width strip directly above the board, styled with the serif font at larger size. This gives it appropriate visual weight and removes it from the cramped side panel. - -### 10c. Action buttons below the board (or in score panels) -"Continue," "Go," and "Hold" buttons can live below the board in a centered button row. The side panel then becomes purely informational (scoring panels), which can slide in from the right. - -### 10d. Mobile: rotate board 90° option -The board is ~776px wide. On narrow screens, offer a portrait mode where the board is rendered rotated 90° (each player's quarters stacked vertically), with a scroll-independent panel above/below for controls. - ---- - -## 11. Login Screen: Form → Atmosphere - -**Current state**: A plain 320px-wide column with a `

Trictrac

`, a text input, and three buttons. Functional but gives no sense of what the game is. - -**Physical game**: A trictrac board is an object of beauty — players set it out, prepare the checkers, and roll for first-move privilege. - -**Proposals**: - -### 11a. Illustrated header -A high-quality SVG illustration of the board (simplified top-down view, showing the triangular fields, checker stacks at starting positions, dice) as the page hero. Possibly animated: the two stacks slowly deploying two checkers as the page loads. - -### 11b. Typography treatment -"TRICTRAC" as a large display heading in a classical-weight serif, possibly with subtle tracking and a gilt color. Below it, the French subtitle: *"Jeu de trictrac — XVIIIe siècle"* in small-caps at reduced opacity. - -### 11c. Mode selection -The three buttons (Create / Join / vs Bot) styled as wooden tiles or embossed cards rather than plain buttons. - ---- - -## 12. Game-Over Modal: Generic → Ceremonial - -**Current state**: A centered modal with "Game Over," the winner's name, and Quit/Play Again buttons. - -**Physical game**: The end of a game involves settling accounts, noting the final hole count, and potentially recording results. - -**Proposals**: -- Show a **final score parchment** — both players' hole counts displayed like a ledger entry, with the winner's name engraved in gilt text -- Animate the modal entrance with a slight downward reveal (the parchment "unrolling") -- Show the hole difference: `"8 — 3"` in large numerals with a small flourish between them -- If bredouille applied to the winning holes: `"✕ 2 bredouille"` annotation -- "Play again" styled as "Rejouer" / "Play again" with a dice icon - ---- - -## Implementation Priority - -| Priority | Proposal | Effort | Impact | -|----------|-----------|--------|--------| -| 1 | §9 Fix Hold button (bug) | Low | Correctness | -| 2 | §3 Rest corner special appearance | Low | Clarity | -| 3 | §8b–d Forbidden jan + exit + corner cues | Medium | Clarity | -| 4 | §5a–d Dice roll animation | Medium | Delight | -| 5 | §6a–b Scoring toasts + animation | Medium | Drama | -| 6 | §7a Hole tracker (12 peg dots) | Low | Authenticity | -| 7 | §2a–b Jan zone labels + color shift | Low | Orientation | -| 8 | §4a Checker slide animation | High | Polish | -| 9 | §1 Triangular fields | High | Authenticity | -| 10 | §10a–b Dice in bar + status above board | Medium | Layout | -| 11 | §6e Hit ripple animation | Medium | Comprehension | -| 12 | §11 Login redesign | Medium | First impression | -| 13 | §12 Game-over modal | Low | Finish | -| 14 | §4c SVG checkers | Medium | Aesthetics | -| 15 | §7b–c Token tracker on rail | High | Authenticity | - ---- - -## Typography and CSS Variables Proposal - -Replace the anonymous `sans-serif` body font and introduce a CSS variable system: - -```css -@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;600&family=Jost:wght@300;400;500&display=swap'); - -:root { - /* Board */ - --board-felt: #1d3d28; - --board-rail: #2a1508; - --field-ivory: #f0e6c8; - --field-burgundy: #7a1e2a; - --field-corner: #c8a030; /* rest corner accent */ - --field-exit-glow: #e8c060; - - /* Checkers */ - --checker-white: #f5edd8; - --checker-black: #1a0f06; - --checker-ring: #c8a448; /* gilt border */ - - /* UI */ - --ui-parchment: #f2e8d0; - --ui-parchment-dark: #e4d8b8; - --ui-ink: #2a1a08; - --ui-gold: #c8a448; - --ui-gold-dark: #8a6a28; - --ui-green-accent: #3a6b2a; - --ui-red-accent: #7a1e2a; - - /* Typography */ - --font-display: 'Cormorant Garamond', Georgia, serif; - --font-ui: 'Jost', system-ui, sans-serif; -} -``` diff --git a/doc/client_web_design_proposals_alternative.md b/doc/client_web_design_proposals_alternative.md deleted file mode 100644 index 5e2e361..0000000 --- a/doc/client_web_design_proposals_alternative.md +++ /dev/null @@ -1,381 +0,0 @@ -# client_web — Alternative Design Proposals: Neon Arcade Future - -A second aesthetic direction: bold, playful, unapologetically modern. Where the first proposal channels an 18th-century gaming salon, this one asks: *what if trictrac ran on a holographic table in a Tokyo arcade, 2089?* - -This document proposes a complete visual redesign with no obligation to mirror physical game objects. The priority is delight, readability, and memorability. - ---- - -## Aesthetic Direction: "Holographic Arcade" - -**Core concept**: The board floats in dark space as a self-illuminated slab. Fields pulse with neon light. Checkers are luminous marbles that leave light trails as they move. Scoring events trigger particle explosions. Every interaction has a micro-animation. - -**The one unforgettable thing**: When a hole is won, the entire board floods with a colour wave — a full-screen shimmer that fades in 800ms — like a pinball machine tilting into multiball. - -**Color palette**: Built on darkness, with high-saturation accents — cyan, magenta, gold. Not gradients on white (the generic AI aesthetic); instead, near-black backgrounds with glowing, luminous elements. - -**Typography**: [Space Grotesk](https://fonts.google.com/specimen/Space+Grotesk) is overused. Instead: -- Display: [Syne](https://fonts.google.com/specimen/Syne) — geometric, confident, slightly alien -- Numerics: [DM Mono](https://fonts.google.com/specimen/DM+Mono) — for scores, dice values, field numbers — crisp monospace with personality -- UI labels: [Outfit](https://fonts.google.com/specimen/Outfit) — friendly, modern, clear at small sizes - -```css -:root { - /* Base */ - --void: #09090f; /* near-black with blue tint */ - --surface: #12121f; /* board slab */ - --surface-raised: #1a1a2e; /* panels, cards */ - --surface-glass: rgba(255,255,255,0.05); /* glassmorphism */ - - /* Neon accents */ - --cyan: #00e5ff; - --cyan-dim: #0099bb; - --magenta: #e040fb; - --gold: #ffd740; - --gold-dim: #c8a820; - --green-neon: #69ff47; - --orange-neon: #ff6d3a; - - /* Player colors */ - --player-white: #e8e0ff; /* soft violet-white */ - --player-black: #1a0040; /* deep indigo-black */ - --player-white-glow: #b39ddb; - --player-black-glow: #7c4dff; - - /* Typography */ - --font-display: 'Syne', sans-serif; - --font-mono: 'DM Mono', monospace; - --font-ui: 'Outfit', sans-serif; - - /* Glow radii */ - --glow-sm: 0 0 8px; - --glow-md: 0 0 16px; - --glow-lg: 0 0 32px; -} -``` - ---- - -## 1. Board: A Floating Holographic Slab - -**Concept**: The board is a dark rectangular surface that appears to float — slight perspective tilt (CSS `perspective` + `rotateX(3deg)`), a thin neon border (1px cyan on top, 1px dimmer on bottom for depth), and a subtle inner glow that makes the board feel luminous from within. - -```css -.board { - background: var(--surface); - border: 1px solid var(--cyan-dim); - box-shadow: - 0 0 0 1px rgba(0,229,255,0.1), - 0 0 40px rgba(0,229,255,0.08), - 0 24px 60px rgba(0,0,0,0.8); - transform: perspective(1200px) rotateX(2deg); - transform-origin: center bottom; - border-radius: 4px; -} -``` - -The board background gets a very subtle **noise texture overlay** (SVG `` or a PNG grain layer at 3% opacity) — just enough to prevent it from looking like a flat rectangle, giving it material presence. - -The center bar and side bars become **glowing dividers**: 4px wide, gradient from `var(--cyan)` at top to `var(--magenta)` at bottom, with a matching glow. - ---- - -## 2. Fields: Neon Triangles with Zone Color Identity - -Triangular fields (CSS `clip-path: polygon`) are essential here — they're geometric and modern, not just historically authentic. - -Each quarter gets its **own neon color identity**, using a very dark base with a glowing triangle border: - -| Quarter | Fields | Primary accent | Secondary (alternating) | -|---------|--------|---------------|------------------------| -| Small jan | 1–6 | `#00e5ff` (cyan) | `#0077aa` (dim cyan) | -| Big jan | 7–12 | `#7c4dff` (violet) | `#4a2a99` (dim violet) | -| Return jan | 13–18 | `#e040fb` (magenta) | `#991a99` (dim magenta) | -| Last jan | 19–24 | `#ffd740` (gold) | `#aa8800` (dim gold) | - -The field itself is dark (`#14141f`). The color lives in a **glowing triangle border** — achieved with a layered `clip-path` + `::before` pseudo-element 2px larger that shows through as the border, with a CSS `filter: blur(3px)` outer glow: - -```css -.field { - background: #14141f; - clip-path: polygon(50% 0%, 0% 100%, 100% 100%); - position: relative; -} -.field::before { - content: ''; - position: absolute; - inset: -2px; - background: var(--field-accent-color); - clip-path: polygon(50% 0%, 0% 100%, 100% 100%); - filter: blur(4px); - opacity: 0.4; - z-index: -1; -} -``` - -**On hover (clickable fields)**: the glow intensifies (`opacity: 0.9`, `filter: blur(6px)`) and the field interior lightens slightly. A ripple animation radiates outward from the click point. - -**Selected field**: the entire field interior fills with a semi-transparent neon color — not just the border — and a 2px dashed animated border spins around it (`animation: spin-border 1s linear infinite`). - ---- - -## 3. Checkers: Luminous Marbles - -Forget CSS circles with radial gradients. Each checker is a **glowing orb** with: - -- A dark, slightly translucent core -- A radial highlight in the upper-left (simulating a point light source) -- A colored halo that radiates outward onto the field triangle -- A subtle inner reflection ring - -```css -.checker.white { - background: radial-gradient(circle at 35% 30%, - #ffffff, - #c8c0e0 40%, - #8878c0 70%, - #3a2a60 - ); - box-shadow: - inset 0 2px 6px rgba(255,255,255,0.8), - inset 0 -2px 4px rgba(0,0,0,0.4), - 0 0 12px rgba(179,157,219,0.6), /* violet-white glow */ - 0 0 24px rgba(124,77,255,0.3); /* outer violet halo */ -} - -.checker.black { - background: radial-gradient(circle at 35% 30%, - #7c4dff, - #4a2d99 40%, - #1a0a40 70%, - #09040f - ); - box-shadow: - inset 0 2px 6px rgba(124,77,255,0.5), - inset 0 -2px 4px rgba(0,0,0,0.8), - 0 0 12px rgba(124,77,255,0.7), - 0 0 24px rgba(124,77,255,0.3); -} -``` - -**Stack depth**: A stack of N checkers renders with each checker offset by 6px vertically and slightly scaled (0.97× per level deeper), creating genuine 3D stack depth without any 3D CSS transform. The count label floats above as a monospace number in `var(--gold)`. - -**Selection animation**: On click to select, the top checker of the stack does a quick `scale(1.2) translateY(-8px)` bounce (150ms spring easing), then settles at `scale(1.1) translateY(-4px)` while selected. - -**Movement animation**: When a move is confirmed (board state diff), selected checkers do a **light-trail arc** — a bezier path from origin field center to destination, with a fading cyan streak left behind (`box-shadow` animated along the path via `@property` interpolation or JS Web Animation API). Duration: 300ms. - ---- - -## 4. Dice: Holographic Crystals - -Replace the SVG ivory dice with **translucent crystal cubes**: - -- Each die face is a dark glass square with a thin neon border -- Pips are glowing dots — cyan for normal, gold for doubles -- The die face has a subtle `backdrop-filter: blur(4px)` on a glass background - -```css -.die-face rect { - fill: rgba(255, 255, 255, 0.04); - stroke: var(--cyan); - stroke-width: 1.5; - rx: 6; - filter: drop-shadow(0 0 6px var(--cyan)); -} -.die-face circle { - fill: var(--cyan); - filter: drop-shadow(0 0 4px var(--cyan)); -} -``` - -**Double dice**: Both pips and borders switch to `var(--gold)`, with a stronger glow (`drop-shadow(0 0 8px var(--gold))`). - -**Roll animation**: 600ms sequence — -1. Both dice **shatter outward** (`scale(0) rotate(720deg)`, opacity 0 → 1) appearing from nothing -2. During 400ms they rapidly cycle through face values (random pips swap every 60ms via CSS `animation`) -3. Final 200ms they decelerate and **snap** to the rolled values with a brief flash pulse - -**Used die**: Fades the border to `rgba(255,255,255,0.1)` and dims pips to `rgba(255,255,255,0.2)` — the die goes "offline." A thin strikethrough line appears diagonally. - ---- - -## 5. The Hole Tracker: Orbital Rings - -Instead of progress bars, score and hole progress are visualised as **concentric orbital rings** beside each player's name panel — inspired by loading spinners, but static and data-driven. - -- **Outer ring** (thick, 6px): hole progress. 12 segments, each one lights up as a hole is won. Segments are `var(--gold)` when won, near-invisible dark when empty. -- **Inner ring** (thin, 3px): point progress within the current hole. Continuously filled arc from 0° to (points/12 × 360°). Color: `var(--cyan)` for the active player, `var(--magenta)` for the opponent. - -The arc fills animate with `stroke-dashoffset` transition (0.4s ease-out) on every point gain. - -**Bredouille state**: The outer ring segments pulse — a slow `opacity: 0.6 → 1 → 0.6` sinusoidal glow — as long as bredouille is active. A small flag icon (⚑) in `var(--gold)` appears beside the ring. - ---- - -## 6. Scoring Events: Light Shows - -### Hole won — Full-board colour wave -A `position:fixed` `::after` overlay expands from the scoring player's side of the board: -- Radial gradient expanding from one edge: `rgba(255,215,64,0)` → `rgba(255,215,64,0.15)` → `rgba(255,215,64,0)` -- Duration: 800ms, ease-in-out -- Simultaneously: the scoring player's orbital rings segments animate sequentially (each segment snaps on with a 50ms delay) -- A large centered text `"+1 TROU"` in `var(--font-display)` at 3rem scales from 60% to 110% with `opacity: 0 → 1 → 0`, duration 1.2s - -### Bredouille — The cascade -On top of the hole wave, add: -- A **confetti burst** of small colored squares (pure CSS: 20 `` elements with randomised `animation-delay` and `translate`/`rotate` keyframes) in cyan, magenta, gold -- The `"+1 TROU"` text instead reads `"BREDOUILLE ×2"` in `var(--magenta)` -- The board border flashes: `border-color` cycles cyan → magenta → gold → cyan over 0.6s - -### Jan scored — Notification card -Each jan scored gets a **toast card** that slides in from the right edge: -- Dark glass background (`rgba(26,26,46,0.95)`) with a left border in the jan's quarter color -- Jan name in `var(--font-ui)` bold, points in `var(--font-mono)` large -- Progress: `"+4 pts"` in cyan, `"+6 pts (double)"` in gold -- Cards stack vertically if multiple jans fire; each staggered by 80ms -- Auto-dismiss with a rightward slide-out after 3s - -### Hit scored — Ripple on the target checker -When a hit is scored on a specific field, that field's checker emits a **sonar ripple**: -- 3 concentric rings expand from the checker's center, each `opacity: 1 → 0, scale: 1 → 2.5` -- Color: cyan for true hits, magenta for false hits (giving to opponent) -- Duration: 600ms per ring, staggered by 200ms - ---- - -## 7. Player Panels: Glassmorphism Cards - -Replace the cream `background: #f5edd8` panels with **glass cards** floating above the void: - -```css -.player-score-panel { - background: rgba(255, 255, 255, 0.04); - border: 1px solid rgba(255, 255, 255, 0.1); - border-top: 1px solid rgba(255, 255, 255, 0.2); /* top catches light */ - backdrop-filter: blur(12px) saturate(1.5); - border-radius: 12px; - box-shadow: - 0 8px 32px rgba(0, 0, 0, 0.4), - inset 0 1px 0 rgba(255, 255, 255, 0.08); -} -``` - -**Active player panel**: the border glow of the active player's card brightens: `border-color: var(--cyan)` with `box-shadow: 0 0 16px rgba(0,229,255,0.2)`. A tiny animated pulse on the left edge (`width: 3px, animation: pulse 1.5s ease-in-out infinite`) indicates it is their turn. - -**Player name**: Displayed in `var(--font-display)` at 1.1rem. A small colored dot (cyan for player 1, magenta for player 2) precedes the name — acts as the "you" indicator without needing a text suffix. - ---- - -## 8. Status Bar: Dynamic Ambient Messaging - -Replace the single plain-text status line with a **contextual bar** that changes character per game stage: - -| Stage | Style | Color | -|-------|-------|-------| -| Waiting for opponent | Slow pulsing dots animation `... ` | Dim white | -| Your turn (roll) | "YOUR MOVE" in `var(--font-display)` with a blinking cursor | Cyan | -| Opponent's turn | Subtle shimmer on text | Dim magenta | -| Move selection | "SELECT CHECKER ①" with animated underline on "SELECT" | Cyan | -| Hold or Go | "SCORE!" with a spinning star ✦ | Gold | -| Paused (continue) | The bar has a pulsing amber background strip | Amber | -| Game over | Text cycles through all player colors | Full rainbow | - -The bar itself is 3px tall and spans the full board width, showing a **neon progress shimmer** during the opponent's turn (a traveling gleam, like CSS `animation: shimmer` on a gradient). - ---- - -## 9. Jan Zone Awareness: Neon Underlay - -Rather than labels, the four quarters glow with their zone color in the background of the board — very subtle, just 4% opacity fills under the triangles: - -```css -.board-quarter-small-jan { background: rgba(0, 229, 255, 0.04); } -.board-quarter-big-jan { background: rgba(124, 77, 255, 0.04); } -.board-quarter-return-jan { background: rgba(224, 64, 251, 0.04); } -.board-quarter-last-jan { background: rgba(255, 215, 64, 0.04); } -``` - -When hovering a scoring-notification row that references a specific jan (e.g. "Big jan conserved"), the corresponding quarter's background pulses from 4% → 15% opacity for 600ms. This replaces the arrow overlay with a spatial, zone-level highlight — more legible and visually coherent. - ---- - -## 10. Rest Corner: The Crown Field - -Field 12 (White) and 13 (Black) get a distinct appearance: - -- The triangle is outlined in `var(--gold)` instead of its quarter's color -- A small **crown SVG** (⚜ or ♛) floats centered in the triangle at 30% opacity when empty, brighter when held -- When the player holds the corner (2 checkers there), the triangle interior fills with a very subtle gold shimmer animation (`background-position: 0% → 100%` on a diagonal gradient, 2s loop) -- When the corner is available to be taken *par puissance*, the crown pulses at 1Hz - ---- - -## 11. Login Screen: Warp Speed Entrance - -**Hero**: A dark void with an animated **particle field** — small white/cyan dots drifting slowly, like stars. Pure CSS with 50 `` elements (or a single `` for performance), each with randomised `animation-delay` and drift keyframe. - -**Title**: "TRICTRAC" in `var(--font-display)` at 5rem, with a **chromatic aberration effect** — three slightly offset copies in cyan, magenta, and white, blended with `mix-blend-mode: screen`. The word appears with a `clip-path: inset(100% 0 0 0) → inset(0% 0 0 0)` reveal animation (the text "rises" into view). - -**Tagline**: `"XVIIIe siècle · En ligne · ∞"` in `var(--font-mono)` at 0.85rem, appearing letter-by-letter with a 20ms interval. - -**Mode cards**: Instead of three buttons, three **holographic tiles** in a row: -- Each is a glass card with an icon, label, and a colored accent strip on the bottom -- On hover: the card lifts (`translateY(-4px)`) and the bottom strip color floods the card (low opacity fill) -- CREATE: cyan accent; JOIN: violet accent; vs BOT: orange accent - -**Room code input**: Dark glass input with a cyan border glow on focus, monospace font for the code, no placeholder text (just a blinking cursor showing it's ready). The input border animates a traveling gleam on focus. - ---- - -## 12. Game-Over Screen: Score Reveal Ceremony - -Instead of a modal over a frozen game, the game-over sequence is a **full-page takeover**: - -1. **Board fades out** (800ms fade): the board dims to 20% opacity -2. **Score card rises** from the bottom: a tall glass card with both players' hole counts displayed large in `var(--font-display)` — `"8"` vs `"3"` — in their respective colors -3. **Winner highlight**: the winning number scales up to 200% with a gold burst radiation behind it -4. **Bredouille annotation**: if applicable, `"× 2"` appears beside the number with a magenta glow, then the number updates to the effective doubled count -5. **Continue options**: two buttons slide up last — "QUIT" and "REJOUER" — with the rejouer button pulsing in cyan - ---- - -## 13. Global Micro-Interactions - -These apply throughout and give the interface a consistently tactile feel: - -- **Button press**: `scale(0.96)` on `:active`, 80ms, then spring back. No `opacity` change — scale is more physical. -- **Button focus**: neon outline ring animated in from 0 to full radius (not the browser default outline). -- **Panel hover**: glass cards shift `box-shadow` slightly for a lifted feel. -- **Page load**: all elements stagger in with a `translateY(10px) → 0 + opacity 0 → 1`, each component with a `animation-delay` offset (board: 0ms, panels: 100ms, side panel: 200ms). -- **Custom cursor** (optional): replace the default cursor with a small circle that trails slightly behind the real cursor position — creates a luxurious "lag" feeling. Pure JS: interpolate cursor position toward mouse position at 80% each frame. - ---- - -## Implementation Notes for Leptos/WASM - -### What's straightforward in pure CSS -- All color variables, glass panels, glow effects, orbital rings (SVG `stroke-dashoffset`) -- Dice roll animation (CSS keyframes) -- Toast slide-ins (CSS `@keyframes` + `animation`) -- Confetti (CSS `@keyframes` on positioned `
` elements) -- Particle field on login (CSS-only with many `` elements) - -### What needs a small JS/WASM component -- **Board perspective tilt** with mouse-tracking (subtle parallax) — `mouse_position` signal driving CSS custom property -- **Checker light-trail movement** — needs previous/next board diff, then Web Animation API or `requestAnimationFrame` -- **Chromatic aberration on title** — CSS filter or SVG filter, but the animation needs JS timing - -### What needs Rust/Leptos state -- **Board diff for animation**: store previous `[i8; 24]` alongside current in `GameScreen` as a `Memo`, compute moved checkers -- **Event timing for sequences**: hole-won wave → score reveal → dismiss must be orchestrated; a `RwSignal>` in `GameScreen` drives each phase - -### Progressive approach -The proposals above can be adopted incrementally. Suggested order: -1. CSS variables + dark theme + Syne/DM Mono fonts → immediate impact, zero logic change -2. Glass panels, neon borders, glow effects → pure CSS -3. Orbital ring score tracker → SVG component -4. Triangular fields + zone colors → `board.rs` structural change -5. Dice animation → CSS keyframes in `die.rs` -6. Toast notifications → new `toast.rs` component -7. Hole-won wave → CSS overlay + Leptos signal -8. Checker animation → board diff + Web Animation API diff --git a/doc/client_web_overview.md b/doc/client_web_overview.md deleted file mode 100644 index 980ea2f..0000000 --- a/doc/client_web_overview.md +++ /dev/null @@ -1,289 +0,0 @@ -# client_web Crate Overview - -A Leptos-based WASM frontend for trictrac. Builds to a single-page app served by Trunk on port 9092. - ---- - -## File Structure - -``` -client_web/ -├── Cargo.toml # Dependencies and i18n locale config -├── Trunk.toml # Serve port 9092 -├── index.html # Shell: mounts WASM + links CSS -├── assets/style.css # All styles (~472 lines, no framework) -├── locales/ -│ ├── en.json # 52 English keys -│ └── fr.json # 52 French keys -└── src/ - ├── main.rs # load_locales!() macro + mount_to_body - ├── app.rs # Root App component, state, network loop (571 lines) - ├── components/ - │ ├── mod.rs - │ ├── game_screen.rs # Main in-game UI, move staging (324 lines) - │ ├── board.rs # Board rendering and click handling (372 lines) - │ ├── die.rs # SVG die face - │ ├── score_panel.rs # Points/holes bar for one player - │ ├── scoring.rs # Jan-by-jan scoring notification panel - │ ├── login_screen.rs # Room create/join - │ └── connecting_screen.rs - └── trictrac/ - ├── mod.rs - ├── types.rs # Protocol types: ViewState, JanEntry, PlayerAction, … (217 lines) - ├── backend.rs # BackEndArchitecture impl, engine bridge (332 lines) - └── bot_local.rs # Local bot: random moves, always Go (34 lines) -``` - ---- - -## Component Tree - -``` -App ← manages screen, pending queue, network task -└─ I18nContextProvider - ├─ LoginScreen ← room name input, create/join/bot buttons - ├─ ConnectingScreen ← spinner while connecting - └─ GameScreen ← in-game UI; receives GameUiState prop - ├─ PlayerScorePanel ← opponent score (above board) - ├─ Board ← 24 interactive fields; SVG arrow overlay - ├─ side panel - │ ├─ status bar ← localised turn/action prompt - │ ├─ dice bar ← two Die components - │ ├─ ScoringPanel (me) ← my jans this turn, hold/go buttons - │ ├─ ScoringPanel (opponent) ← opponent jans (shown during pause) - │ └─ action buttons ← Continue / Go / Empty Move - └─ PlayerScorePanel ← my score (below board) - [game-over overlay modal] -``` - ---- - -## Screens and Transitions - -``` -Login ──(connect)──→ Connecting ──(game start)──→ Playing - ↑ │ - └──(reconnect)─────┘ -Playing ──(disconnect / game over)──→ Login -``` - -`app.rs` drives transitions via `RwSignal`. - ---- - -## State Management - -### Root signals (live in `App`, provided via Leptos context) - -| Signal | Type | Purpose | -|--------|------|---------| -| `screen` | `RwSignal` | Which screen is shown | -| `pending` | `RwSignal>` | Buffered states awaiting "Continue" | -| `cmd_tx` | `UnboundedSender` | UI → network command channel | - -Both `pending` and `cmd_tx` are provided as context so any descendant can read/write them without prop-drilling. - -### GameScreen-local signals - -| Signal | Type | Purpose | -|--------|------|---------| -| `selected_origin` | `RwSignal>` | First clicked field during move staging | -| `staged_moves` | `RwSignal>` | Accumulated (origin, dest) pairs for this turn | -| `hovered_jan_moves` | `RwSignal>` | Moves to draw arrows for on hover | - -### Data flow - -``` -Network task (async in App) - ↓ SessionEvent::Update -push_or_show() → pending queue or screen.set() - ↓ -GameScreen re-renders (GameUiState prop) - ↓ -User clicks field → staged_moves effect → NetCommand::Action(Move) -User clicks Go/Continue → cmd_tx.send or pending.pop_front() -``` - ---- - -## Network and Session - -The multiplayer layer is provided by `backbone-lib` (local fork at `../../forks/multiplayer/`). `App` spawns an async task (via `spawn_local`) that multiplexes: -- `cmd_rx`: commands from UI components -- `session.next_event()`: updates from the server - -### StoredSession (localStorage key: `"trictrac_session"`) - -```rust -struct StoredSession { - relay_url: String, - game_id: String, - room_id: String, - token: u64, // reconnect token issued by server - is_host: bool, - view_state: Option, // host saves last known state; guest saves None -} -``` - -On page load, if a stored session exists, App goes directly to Connecting and sends `NetCommand::Reconnect`. Failed reconnects clear the session and return to Login. - ---- - -## Pause / Confirmation Flow - -Certain opponent events are paused so the local player can see what happened before their turn starts. - -Pause triggers (`infer_pause_reason()` in `app.rs`): - -| Reason | Condition | -|--------|-----------| -| `AfterOpponentRoll` | Opponent is active; dice values changed | -| `AfterOpponentGo` | Opponent chose Go (HoldOrGoChoice→Move transition) | -| `AfterOpponentMove` | Turn switched to us | - -While a state is in the pending queue, `GameScreen` shows a "Continue" button. Clicking it calls `pending.pop_front()`; if the queue empties, the live state is displayed. - ---- - -## Game Engine Integration - -**File**: `src/trictrac/backend.rs` - -`TrictracBackend` implements the `BackEndArchitecture` trait. It owns a `GameState` from `trictrac-store` and translates between the UI protocol and the engine's event model. - -### PlayerAction → GameEvent mapping - -| PlayerAction | GameEvents emitted | -|---|---| -| `Roll` | `GameEvent::Roll`, `GameEvent::RollResult(d1, d2)` | -| `Move(m1, m2)` | `GameEvent::Move` (after validation) | -| `Go` | `GameEvent::Go` | -| `Mark` | internal; drives `MarkPoints`/`MarkAdvPoints` loop automatically | - -`drive_automatic_stages()` loops through scoring stages without waiting for player input — these are not interactive in the current implementation (schools are not implemented). - -### ViewState construction - -`ViewState::from_game_state()` in `types.rs` converts the engine state to the serialisable snapshot sent to clients: -- `board: [i8; 24]` — direct copy of `Board::positions` -- `dice: [u8; 2]` — current dice values -- `stage / turn_stage` — serialisable enums (`SerStage`, `SerTurnStage`) -- `scores: [PlayerScore; 2]` — points, holes, `can_bredouille` -- `dice_jans: Vec` — scoring events for the current turn, sorted descending by points -- `active_player_index: usize` — 0 = host, 1 = guest - -### Bot - -`bot_local.rs` runs in the browser (no server call). It inspects `GameState` directly and returns a `PlayerAction`: -- **RollDice**: always Roll -- **HoldOrGoChoice**: always Go -- **Move**: picks a random legal sequence from `MoveRules::get_possible_moves_sequences()`; mirrors moves because Black's board is mirrored - ---- - -## Board Rendering (`board.rs`) - -### Layout - -The 24 fields are split into 4 quarters of 6. Each player sees the board from their own perspective: - -``` -White's view: - TOP-LEFT [13–18] | TOP-RIGHT [19–24] - ───────────────────────────────────────── - BOT-LEFT [12–7] | BOT-RIGHT [6–1] - -Black's view (mirror): - TOP-LEFT [1–6] | TOP-RIGHT [7–12] - ───────────────────────────────────────── - BOT-LEFT [24–19] | BOT-RIGHT [18–13] -``` - -Fields are 60 × 180 px, alternating gold (`#d4a843` / `#c49030`). Checkers are 40 px SVG circles (radial gradient). Up to 4 are stacked visually; a text label is shown when count > 4. - -### Highlighting - -Field CSS classes are computed reactively inside the `view!` macro closure: - -| Class | Meaning | -|-------|---------| -| `.clickable` | Valid origin during Move stage (lime green) | -| `.selected` | Currently selected origin (darker green + outline) | -| `.dest` | Valid destination for the selected origin | - -`valid_sequences` (from `MoveRules`) is computed once per render and used to derive `valid_origins_for()` and `valid_dests_for()`. The displayed checker count (`displayed_value()`) accounts for staged-but-not-yet-sent moves so the board previews the move visually. - -### SVG arrow overlay - -When the player hovers a row in the ScoringPanel, the corresponding checker moves are drawn as gold arrows over the board. `field_center()` maps field numbers to pixel coordinates; `arrow_svg()` renders the path with a drop-shadow. - ---- - -## Scoring Display (`scoring.rs`, `score_panel.rs`) - -`compute_scored_event()` in `app.rs` diffs consecutive `ViewState` snapshots to produce a `ScoredEvent`: -- `points_earned: i32` -- `holes_gained: u8` -- `jans: Vec` — only events relevant to the beneficiary - -`ScoringPanel` renders one `JanEntry` per row. Hovering a row writes that entry's moves into `hovered_jan_moves`, triggering the arrow overlay on the board. - -`PlayerScorePanel` shows a colour-filled bar (animated via CSS `transition: width 0.3s`) for points (0–12) and holes (0–12). Bredouille state is shown with a small indicator. - ---- - -## Internationalisation - -`leptos_i18n::load_locales!()` is a compile-time macro that reads `locales/en.json` and `locales/fr.json` and generates a typed `i18n` module. There are 52 keys covering UI labels, game-state prompts, jan names, and status messages. - -Usage in components: -```rust -let i18n = use_i18n(); -t!(i18n, your_turn_roll) // → reactive View -t_string!(i18n, scored_pts, pts = 4) // → String with interpolation -``` - -The language switcher (top bar and login screen) calls `i18n.set_locale(Locale::en | Locale::fr)`, which triggers a full reactive re-render. - ---- - -## Styling - -`assets/style.css` is a single hand-written stylesheet (~472 lines). No CSS framework. - -Key design tokens: -- Body background: `#c8b084` (tan) -- Board background: `#2e6b2e` (dark green) -- Fields: `#d4a843` / `#c49030` (gold alternating) -- Interactive fields: `#aad060` (lime, clickable) / `#709a20` (darker, selected) -- UI panels: `#f5edd8` (cream) - -Layout uses Flexbox and CSS Grid throughout. Score bars animate with `transition: width 0.3s`. Field clicks give immediate feedback via `transition: background 0.1s`. No media queries — the layout is designed for desktop/tablet. - ---- - -## Protocol Types (`types.rs`) - -| Type | Role | -|------|------| -| `PlayerAction` | `Roll \| Move(CheckerMove, CheckerMove) \| Go \| Mark` — UI → backend | -| `GameDelta` | `{ state: ViewState }` — broadcast to all clients on every change | -| `ViewState` | Full serialisable snapshot of engine state | -| `JanEntry` | One scoring event: jan type, points, ways, moves, is_double | -| `ScoredEvent` | Points/holes delta + jan list for one player in one turn | -| `PlayerScore` | name, points (0–11), holes (0–12), can_bredouille | -| `SerStage` | `PreGame \| InGame \| Ended` | -| `SerTurnStage` | `RollDice \| RollWaiting \| MarkPoints \| HoldOrGoChoice \| Move \| MarkAdvPoints` | - -`CheckerMove` comes directly from `trictrac-store`; fields are 1-indexed (0 = stack/exit). - ---- - -## Build - -```bash -trunk serve # dev server at http://localhost:9092 -trunk build --release # WASM release bundle -``` - -`index.html` uses Trunk's `data-trunk` attributes: `rel="rust"` compiles `src/main.rs` to WASM; `rel="css"` copies `assets/style.css`. The WASM binary and generated JS glue land in `dist/`. diff --git a/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87.html b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87.html new file mode 100644 index 0000000..e2a19dc --- /dev/null +++ b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87.html @@ -0,0 +1,12 @@ + + + + + + Trictrac + + + + + +
Anonyme (vous)
6/12
Bot
2/12
+4 pts
Battage à vrai (petit jan)simple×1+4
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
11
12
11
10
9
8
7
6
5
4
3
2
1
10
grand jan
petit jan
Déplacez une dame (1 sur 2)

Cliquez une flêche soulignée pour déplacer

diff --git a/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css new file mode 100644 index 0000000..24df8c0 --- /dev/null +++ b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style-e86a95086579e325.css @@ -0,0 +1,2305 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.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; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── 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 { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + 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; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/doc/ui-mockup/style.css b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css similarity index 91% rename from doc/ui-mockup/style.css rename to doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css index 428d693..24df8c0 100644 --- a/doc/ui-mockup/style.css +++ b/doc/design/snapshots/2026-05-30-d4a2ea1c531827bfbc2410af20a00e349d606c87_files/style.css @@ -161,7 +161,7 @@ body { /* ── Stats grid ──────────────────────────────────────────────────── */ .stats-grid { display: grid; - grid-template-columns: repeat(4, 1fr); + grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.5rem; } @@ -305,6 +305,62 @@ a:hover { text-decoration: underline; } .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; } +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + .portal-link { color: var(--ui-gold); text-decoration: none; @@ -1799,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; @@ -2045,6 +2153,7 @@ a:hover { text-decoration: underline; } text-decoration: none; opacity: 0.8; transition: opacity 0.15s; + cursor: pointer; } .game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } @@ -2076,3 +2185,121 @@ a:hover { text-decoration: underline; } max-width: 200px; margin: 0 auto; } + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html new file mode 100644 index 0000000..4ac9d36 --- /dev/null +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169.html @@ -0,0 +1,153 @@ + + + + + + Trictrac + + + + + + + +
Anonymous (you)
6/12
Trictrac
6/12
Bot
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
8
12
11
10
9
8
7
6
5
4
3
2
1
11
grand jan
petit jan
Move a checker (1 of 2)

Click a highlighted field to move a checker

Cannot play in a quarter the opponent can still fill
+2 pts
True hit (big jan)simple×1+2
diff --git a/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css new file mode 100644 index 0000000..58db762 --- /dev/null +++ b/doc/design/snapshots/2026-06-06-7b036e3ee1a8d3d686a664d74717fd23df1d3169_files/style-b42680e382d603c7.css @@ -0,0 +1,2528 @@ +/* ── Google Fonts ───────────────────────────────────────────────── */ +@import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400&family=Jost:wght@300;400;500&display=swap'); + +/* ── Design tokens ──────────────────────────────────────────────────── */ +:root { + --board-felt: #1d3d28; + --board-rail: #2a1508; + --field-ivory: #f0e6c8; + --field-burgundy: #7a1e2a; + --field-blue: #e5eadc; + --field-blue-light: #1a4f72; + --field-brown: #f2dfa0; + --field-brown-light: #6a2810; + --field-corner: #b8900a; + --checker-white: #f5edd8; + --checker-black: #1a0f06; + --checker-ring: #c8a448; + --ui-parchment: #f2e8d0; + --ui-parchment-dark: #e4d8b8; + --ui-ink: #2a1a08; + --ui-gold: #c8a448; + --ui-gold-dark: #8a6a28; + --ui-green-accent: #3a6b2a; + --ui-red-accent: #7a1e2a; + --font-display: 'Cormorant Garamond', Georgia, serif; + --font-ui: 'Jost', system-ui, sans-serif; +} + + +/* ── Reset & base ──────────────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: var(--font-ui); + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + display: flex; + flex-direction: column; + min-height: 100vh; +} + +.hidden { display: none !important; } + +/* -- svg icons -- */ +.icon { + width: 1.2em; + height: 1.2em; + color: var(--ui-parchment); + vertical-align: -0.25em; + margin-right: 0.7em; +} + +/* ── Site navigation ─────────────────────────────────────────────── */ +.site-nav { + background: var(--board-rail); + border-bottom: 2px solid var(--ui-gold-dark); + padding: 0 1.5rem; + height: 52px; + display: flex; + align-items: center; + gap: 1.5rem; + position: sticky; + top: 0; + z-index: 50; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); + flex-shrink: 0; +} + +.site-nav-brand { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-gold); + text-decoration: none; + letter-spacing: 0.1em; +} +.site-nav-brand:hover { color: #e0b840; } + +.site-nav-spacer { flex: 1; } + +.site-nav a { + font-family: var(--font-ui); + font-size: 0.9rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s, color 0.15s; +} +.site-nav a:hover { opacity: 1; } + +.site-nav-btn { + padding: 0.3rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + font-weight: 500; + letter-spacing: 0.03em; + border: 1px solid rgba(200,164,72,0.4); + border-radius: 4px; + background: transparent; + color: var(--ui-parchment); + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.site-nav-btn:hover { + background: rgba(200,164,72,0.12); + border-color: var(--ui-gold); +} + +/* ── Portal main content area ────────────────────────────────────── */ +.portal-main { + flex: 1; + max-width: 900px; + width: 100%; + margin: 2rem auto; + padding: 0 1.5rem; +} + +.portal-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: 0 4px 16px rgba(0,0,0,0.18); */ + /* border: 1px solid rgba(200,164,72,0.3); */ + /* border-top: 3px solid var(--ui-gold-dark); */ + padding: 1.75rem 2rem; + margin-bottom: 1.5rem; +} + +.portal-card h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.25rem; +} +.portal-card h2 { + font-family: var(--font-display); + font-size: 1.35rem; + font-weight: 600; + color: var(--ui-ink); + margin-bottom: 0.75rem; +} + +.portal-meta { + font-size: 0.85rem; + color: #665544; + margin-bottom: 1.5rem; + font-family: var(--font-ui); +} + +/* ── Stats grid ──────────────────────────────────────────────────── */ +.stats-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 1rem; + margin-bottom: 1.5rem; +} +.stat-box { + background: var(--ui-parchment); + border: 1px solid rgba(200,164,72,0.28); + border-radius: 6px; + padding: 1rem; + text-align: center; + box-shadow: 0 1px 4px rgba(0,0,0,0.1); +} +.stat-box .value { + font-family: var(--font-display); + font-size: 2.2rem; + font-weight: 600; + color: var(--ui-gold-dark); +} +.stat-box .label { + font-size: 0.78rem; + color: #665544; + margin-top: 0.2rem; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* ── Tables ──────────────────────────────────────────────────────── */ +table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + font-family: var(--font-ui); +} +th { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 2px solid rgba(200,164,72,0.4); + color: #665544; + font-weight: 500; + font-size: 0.8rem; + letter-spacing: 0.05em; + text-transform: uppercase; +} +td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + color: var(--ui-ink); +} +tr:last-child td { border-bottom: none; } +tr:hover td { background: rgba(200,164,72,0.05); } + +a { color: var(--ui-gold-dark); text-decoration: none; } +a:hover { text-decoration: underline; } + +/* ── Outcome classes ─────────────────────────────────────────────── */ +.outcome-win { color: var(--ui-green-accent); font-weight: 600; } +.outcome-loss { color: var(--ui-red-accent); font-weight: 600; } +.outcome-draw { color: #c07020; font-weight: 600; } + +/* ── Portal tabs ─────────────────────────────────────────────────── */ +.portal-tabs { + display: flex; + gap: 0; + margin-bottom: 1.5rem; + border-bottom: 1px solid rgba(200,164,72,0.3); +} +.portal-tab-btn { + padding: 0.55rem 1.5rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: transparent; + border: none; + cursor: pointer; + color: #665544; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color 0.15s, border-color 0.15s; +} +.portal-tab-btn.active { + color: var(--ui-ink); + border-bottom-color: var(--ui-gold-dark); + font-weight: 500; +} + +/* ── Portal form ─────────────────────────────────────────────────── */ +.portal-label { + display: block; + font-size: 0.82rem; + color: #665544; + margin-bottom: 0.3rem; + letter-spacing: 0.03em; +} +.portal-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.35); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + margin-bottom: 1rem; + transition: border-color 0.15s, box-shadow 0.15s; +} +.portal-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.18); +} +.portal-submit-btn { + padding: 0.6rem 2rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + border: none; + border-radius: 5px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0,0,0,0.25); + transition: opacity 0.15s; +} +.portal-submit-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.portal-submit-btn:not(:disabled):hover { opacity: 0.9; } + +.portal-page-btn { + padding: 0.35rem 0.9rem; + font-family: var(--font-ui); + font-size: 0.85rem; + background: var(--board-rail); + color: var(--ui-parchment); + border: none; + border-radius: 4px; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s; +} +.portal-page-btn:hover { opacity: 1; } + +.portal-loading { color: #665544; font-style: italic; padding: 1rem 0; } +.portal-empty { color: #aa9070; font-style: italic; padding: 1rem 0; } +.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; } + +.flash-banner { + position: fixed; + top: 1.25rem; + left: 50%; + transform: translateX(-50%); + z-index: 500; + display: flex; + align-items: center; + gap: 1rem; + padding: 0.75rem 1.25rem; + background: var(--ui-green-accent); + color: #f5edd8; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0,0,0,0.35); + font-family: var(--font-ui); + font-size: 0.95rem; + max-width: 90vw; + animation: flash-in 0.2s ease; +} +@keyframes flash-in { + from { opacity: 0; transform: translateX(-50%) translateY(-0.5rem); } + to { opacity: 1; transform: translateX(-50%) translateY(0); } +} +.flash-dismiss { + background: none; + border: none; + color: inherit; + cursor: pointer; + font-size: 1rem; + opacity: 0.75; + padding: 0; + line-height: 1; +} +.flash-dismiss:hover { opacity: 1; } + +.portal-danger-zone { + border: 1px solid rgba(122, 30, 42, 0.4); + background: rgba(122, 30, 42, 0.04); +} +.portal-danger-zone h2 { + color: var(--ui-red-accent); +} +.portal-danger-btn { + padding: 0.5rem 1.25rem; + font-family: var(--font-ui); + font-size: 0.9rem; + background: var(--ui-red-accent); + color: #f5edd8; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s; +} +.portal-danger-btn:hover { opacity: 0.85; } +.portal-danger-btn:disabled { opacity: 0.45; cursor: not-allowed; } + +.portal-link { + color: var(--ui-gold); + text-decoration: none; + font-size: 0.875rem; +} +.portal-link:hover { text-decoration: underline; } + +.portal-verification-banner { + background: rgba(200,164,72,0.08); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 6px; + padding: 1.25rem; + text-align: center; +} +.portal-verification-banner p { + margin-bottom: 0.75rem; + font-size: 0.9rem; +} + +/* ── 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 { + position: fixed; + inset: 0; + background: #8a7050; + background-image: + radial-gradient(ellipse at 20% 10%, rgba(80,48,16,0.35) 0%, transparent 60%), + radial-gradient(ellipse at 80% 90%, rgba(40,24,8,0.3) 0%, transparent 55%), + repeating-linear-gradient( + 45deg, transparent, transparent 3px, + rgba(0,0,0,0.03) 3px, rgba(0,0,0,0.03) 4px + ); + z-index: 200; + display: flex; + justify-content: center; + align-items: flex-start; + padding: 1.5rem; + overflow-y: auto; +} + +/* ── Login card (§11) ───────────────────────────────────────────────── */ +.login-card { + background: var(--ui-parchment); + border-radius: 8px; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 3px 3px rgba(42,21,8,0.9) + ; + /* box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + */ + /* border-top: 3px solid var(--ui-gold-dark); */ + width: 340px; + margin-top: 5vh; + overflow: hidden; +} + +/* Decorative header — row of triangular flèches like the actual board */ +.login-card-header { + height: 52px; + background: var(--board-felt); + position: relative; + overflow: hidden; +} + +.login-board-stripe { + position: absolute; + inset: 0; + background: + repeating-linear-gradient( + 90deg, + var(--field-burgundy) 0, var(--field-burgundy) 50%, + var(--field-ivory) 50%, var(--field-ivory) 100% + ); + background-size: 34px 100%; + clip-path: polygon( + 0% 0%, 2.94% 100%, 5.88% 0%, 8.82% 100%, 11.76% 0%, + 14.7% 100%, 17.65% 0%, 20.59% 100%, 23.53% 0%, + 26.47% 100%, 29.41% 0%, 32.35% 100%, 35.29% 0%, + 38.24% 100%, 41.18% 0%, 44.12% 100%, 47.06% 0%, + 50% 100%, 52.94% 0%, 55.88% 100%, 58.82% 0%, + 61.76% 100%, 64.71% 0%, 67.65% 100%, 70.59% 0%, + 73.53% 100%, 76.47% 0%, 79.41% 100%, 82.35% 0%, + 85.29% 100%, 88.24% 0%, 91.18% 100%, 94.12% 0%, + 97.06% 100%, 100% 0% + ); + opacity: 0.9; +} + +.login-card-body { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; + padding: 1.5rem 2rem 2rem; +} + +.login-lang-switcher { + align-self: flex-end; + margin-bottom: 0.75rem; +} + +/* Override lang-switcher colours for the parchment card */ +.login-card .lang-switcher button { + color: var(--ui-ink); + border-color: rgba(42,21,8,0.2); + opacity: 0.5; +} +.login-card .lang-switcher button.lang-active { + opacity: 1; + background: rgba(42,21,8,0.08); + border-color: rgba(42,21,8,0.35); +} + +.login-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.12em; + text-align: center; + line-height: 1; + margin-bottom: 0.3rem; +} + +.login-subtitle { + font-family: var(--font-display); + font-size: 0.85rem; + color: rgba(42,26,8,0.55); + text-align: center; + letter-spacing: 0.06em; + font-style: italic; + margin-bottom: 1.25rem; +} +.login-subtitle sup { + font-size: 0.65em; + vertical-align: super; +} + +.login-ornament { + color: var(--ui-gold); + font-size: 1rem; + opacity: 0.7; + margin-bottom: 1.25rem; + letter-spacing: 0.3em; + text-align: center; +} + +.error-msg { + color: #c03030; + font-size: 0.85rem; + text-align: center; + margin-bottom: 0.5rem; +} + +.login-input { + width: 100%; + padding: 0.55rem 0.85rem; + font-size: 0.95rem; + font-family: var(--font-ui); + border: 1px solid rgba(138,106,40,0.4); + border-radius: 5px; + background: rgba(255,252,240,0.8); + color: var(--ui-ink); + outline: none; + transition: border-color 0.15s, box-shadow 0.15s; + margin-bottom: 1rem; +} +.login-input:focus { + border-color: var(--ui-gold); + box-shadow: 0 0 0 3px rgba(200,164,72,0.2); +} + +.login-actions { + display: flex; + flex-direction: column; + gap: 0.55rem; + width: 100%; +} + +/* Login buttons styled as embossed wooden tiles */ +.login-btn { + width: 100%; + padding: 0.65rem 1rem; + font-family: var(--font-ui); + font-size: 0.9rem; + font-weight: 500; + letter-spacing: 0.04em; + border: none; + border-radius: 5px; + cursor: pointer; + transition: opacity 0.15s, transform 0.1s, box-shadow 0.15s; + position: relative; +} +.login-btn:disabled { opacity: 0.35; cursor: default; } +.login-btn:not(:disabled):hover { opacity: 0.92; transform: translateY(-1px); } +.login-btn:not(:disabled):active { transform: translateY(0); } + +.login-btn-primary { + background: linear-gradient(160deg, #4a7a38 0%, #2e5222 100%); + color: #e8f0e0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.12); +} +.login-btn-secondary { + background: linear-gradient(160deg, #3a2010 0%, #241408 100%); + color: #e4d4b4; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} +.login-btn-bot { + background: linear-gradient(160deg, #2a4a6a 0%, #183050 100%); + color: #d0e0f0; + box-shadow: 0 2px 6px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.08); +} + +/* ── Connecting screen ──────────────────────────────────────────────── */ +.connecting { + font-family: var(--font-display); + font-size: 1.4rem; + font-style: italic; + margin-top: 4rem; + text-align: center; + color: var(--ui-parchment); + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Game-action buttons ─────────────────────────────────────────────── */ +.btn { + padding: 0.5rem 1.25rem; + font-size: 0.95rem; + font-family: var(--font-ui); + font-weight: 500; + letter-spacing: 0.03em; + border: none; + border-radius: 4px; + cursor: pointer; + transition: opacity 0.15s, box-shadow 0.15s; +} +.btn:disabled { opacity: 0.4; cursor: default; } +.btn-primary { background: var(--ui-green-accent); color: #fff; } +.btn-secondary { background: var(--board-rail); color: #e8d8b8; } +.btn-bot { background: #2a5a7a; color: #fff; } +.btn:not(:disabled):hover { + opacity: 0.9; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); +} + +/* ── Game container ─────────────────────────────────────────────────── */ +.game-container { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.6rem; +} + +/* ── Language switcher (in-game) ────────────────────────────────────── */ +.lang-switcher { display: flex; gap: 0.25rem; } + +.lang-switcher button { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.05em; + padding: 0.15rem 0.4rem; + border: 1px solid rgba(200,164,72,0.3); + border-radius: 3px; + background: transparent; + cursor: pointer; + color: var(--ui-parchment); + opacity: 0.55; +} +.lang-switcher button.lang-active { + opacity: 1; + font-weight: 500; + background: rgba(200,164,72,0.15); + border-color: rgba(200,164,72,0.6); +} + +/* ── Top bar ─────────────────────────────────────────────────────────── */ +.top-bar { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + color: var(--ui-parchment); + font-size: 0.85rem; + opacity: 0.8; +} + +.quit-link { + font-size: 0.8rem; + color: var(--ui-parchment); + text-decoration: underline; + text-underline-offset: 2px; + cursor: pointer; + opacity: 0.7; +} +.quit-link:hover { opacity: 1; } + +.playing-as { + font-size: 0.75rem; + color: rgba(242,232,208,0.7); + font-family: var(--font-ui); +} +.playing-as strong { color: rgba(242,232,208,0.9); } + +/* ── Game status bar (§10b) — above board ───────────────────────────── */ +.game-status { + font-family: var(--font-display); + font-size: 1.2rem; + font-style: italic; + color: var(--ui-parchment); + text-align: center; + letter-spacing: 0.04em; + padding: 0.2rem 1rem 0; + width: 100%; + text-shadow: 0 1px 4px rgba(0,0,0,0.4); +} + +/* ── Contextual sub-prompt (§8a) ────────────────────────────────────── */ +.game-sub-prompt { + font-family: var(--font-ui); + font-size: 0.72rem; + color: rgba(240,228,192,0.5); + text-align: center; + letter-spacing: 0.04em; + padding: 0.15rem 1rem 0; + width: 100%; +} + +/* ── Player score panel ─────────────────────────────────────────────── */ +.player-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 1.25rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + align-items: center; + gap: 1.5rem; +} + +.player-score-header { + flex-shrink: 0; + min-width: 90px; +} + +.player-name { + font-family: var(--font-display); + font-weight: 600; + font-size: 1.05rem; + color: var(--ui-ink); + letter-spacing: 0.02em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} + +.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; } + +.score-bar-row { + display: flex; + align-items: center; + gap: 0.5rem; + flex: 1; +} + +.score-bar-label { + font-size: 0.75rem; + color: #665544; + width: 3rem; + text-align: right; + flex-shrink: 0; +} + +/* ── Points bar ─────────────────────────────────────────────────────── */ +.score-bar { + flex: 1; + max-width: 220px; + height: 8px; + background: rgba(0,0,0,0.1); + border-radius: 4px; + overflow: hidden; + flex-shrink: 0; +} + +.score-bar-fill { + height: 100%; + border-radius: 4px; + transition: width 0.35s ease-out; +} + +.score-bar-points { background: linear-gradient(90deg, var(--ui-green-accent), #5a9b3a); } + +.score-bar-value { + font-size: 0.75rem; + color: #665544; + min-width: 2.5rem; + font-variant-numeric: tabular-nums; +} + +/* ── Hole peg tracker (§7a) ─────────────────────────────────────────── */ +.peg-track { + display: flex; + align-items: center; + gap: 3px; + flex-shrink: 0; +} + +.peg-hole { + width: 10px; + height: 10px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.45); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.peg-hole.filled { + background: var(--ui-gold); + border-color: var(--ui-gold-dark); + box-shadow: 0 0 4px rgba(200,164,72,0.6); +} + +.bredouille-badge { + font-size: 0.62rem; + font-weight: 500; + color: #fff8e0; + background: linear-gradient(135deg, #c88800, #8a5800); + border: 1px solid rgba(200,164,72,0.5); + border-radius: 3px; + padding: 0.1em 0.4em; + letter-spacing: 0.06em; + cursor: default; + box-shadow: 0 1px 3px rgba(0,0,0,0.25); +} + +/* ── Merged scoreboard (both players, above board) ──────────────────── */ +.merged-score-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.5rem 1.25rem 0.45rem; + font-size: 0.88rem; + box-shadow: 0 2px 6px rgba(0,0,0,0.25); + width: 100%; + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.score-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.score-row-name { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: baseline; + gap: 0.35rem; + overflow: hidden; +} + +.you-tag { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #887766; + font-style: italic; + white-space: nowrap; + flex-shrink: 0; +} + +/* ── Jackpot points counter ─────────────────────────────────────────── */ +.pts-counter-wrap { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + width: 72px; + flex-shrink: 0; + padding-bottom: 4px; +} + +.pts-ghost-bar-track { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: rgba(0,0,0,0.07); + border-radius: 2px; + overflow: hidden; +} + +.pts-ghost-bar-fill { + height: 100%; + background: rgba(58,107,42,0.45); + border-radius: 2px; +} + +.pts-ghost-bar-opp { + background: rgba(122,30,42,0.4); +} + +.pts-counter-row { + display: flex; + align-items: baseline; + gap: 0.1rem; +} + +.pts-counter { + font-family: var(--font-display); + font-size: 1.9rem; + font-weight: 600; + color: var(--ui-ink); + line-height: 1; + font-variant-numeric: tabular-nums; + min-width: 1.4em; + text-align: right; +} + +.pts-max { + font-family: var(--font-ui); + font-size: 0.7rem; + color: #998877; + line-height: 1; + padding-bottom: 2px; +} + +/* ── Hole pegs — larger and coloured (me = green, opp = red) ─────────── */ +.merged-score-panel .peg-track { + gap: 4px; +} + +.merged-score-panel .peg-hole { + width: 14px; + height: 14px; + border-radius: 50%; + border: 1.5px solid rgba(138,106,40,0.3); + background: rgba(0,0,0,0.06); + flex-shrink: 0; + transition: background 0.3s ease-out, border-color 0.3s, box-shadow 0.3s; +} + +.merged-score-panel .peg-hole.filled { + background: #5aab38; + border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} + +.merged-score-panel .peg-hole.peg-opp.filled { + background: #c05030; + border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Peg pop-in animation when a new hole is scored */ +@keyframes peg-pop { + 0% { transform: scale(0.15); opacity: 0; } + 45% { transform: scale(1.55); } + 70% { transform: scale(0.88); } + 100% { transform: scale(1.0); opacity: 1; } +} + +.merged-score-panel .peg-hole.peg-new { + animation: peg-pop 0.52s cubic-bezier(0.22, 0.61, 0.36, 1) forwards; +} + +/* Thin separator between the two player rows */ +.score-row-sep { + height: 1px; + background: rgba(0,0,0,0.07); + margin: 0.05rem 0; +} + +/* ── Non-blocking hole flash (replaces old toast) ───────────────────── */ +@keyframes hole-flash-in-out { + 0% { opacity: 0; transform: translateY(-3px); } + 14% { opacity: 1; transform: translateY(0); } + 65% { opacity: 1; } + 100% { opacity: 0; transform: translateY(2px); } +} + +.hole-flash { + margin-left: auto; + flex-shrink: 0; + white-space: nowrap; + font-family: var(--font-display); + font-size: 0.88rem; + font-weight: 600; + color: var(--ui-green-accent); + letter-spacing: 0.05em; + animation: hole-flash-in-out 2.5s ease-out forwards; + pointer-events: none; +} + +.hole-flash.hole-flash-bredouille { + color: var(--ui-gold-dark); +} + +/* ── Game bottom strip — status, hints, buttons on cream ────────────── */ +.game-bottom-strip { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.55rem 1.25rem 0.65rem; + width: 100%; + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + border-top: 2px solid var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 0.35rem; + min-height: 3.2rem; +} + +/* Override text colours for the parchment background context */ +.game-bottom-strip .game-status { + color: var(--ui-ink); + text-shadow: none; + padding: 0; + font-size: 1.05rem; + width: auto; +} + +.game-bottom-strip .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; +} + +/* ── Board + side panel ─────────────────────────────────────────────── */ +.board-and-panel { + position: relative; +} + +.side-panel { + position: absolute; + right: -8px; + top: 10px; + z-index: 20; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 0.15rem; + pointer-events: none; +} + +.action-buttons { display: flex; flex-direction: column; gap: 0.5rem; } + +/* ── Dice bar ───────────────────────────────────────────────────────── */ +.dice-bar { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.4rem 0.6rem; + background: rgba(42,21,8,0.15); + border-radius: 6px; + border: 1px solid rgba(200,164,72,0.2); + width: fit-content; +} + +/* ── Die face (SVG) ─────────────────────────────────────────────────── */ +@keyframes die-tumble { + 0% { transform: rotate(-45deg) scale(0.4) translateY(-8px); opacity: 0; } + 25% { transform: rotate(18deg) scale(1.22) translateY(0); opacity: 1; } + 45% { transform: rotate(-10deg) scale(0.91); } + 62% { transform: rotate(6deg) scale(1.06); } + 76% { transform: rotate(-3deg) scale(0.98); } + 88% { transform: rotate(1.5deg) scale(1.01); } + 100% { transform: rotate(0deg) scale(1); opacity: 1; } +} + +.die-face { + filter: drop-shadow(0 2px 3px rgba(0,0,0,0.3)); + animation: die-tumble 0.55s cubic-bezier(0.22, 0.61, 0.36, 1) both; +} + +.die-face rect { + fill: #fffef0; + stroke: #2a1a00; + stroke-width: 2; + transition: fill 0.18s, stroke 0.18s; +} +.die-face circle { + fill: #1a0a00; + transition: fill 0.18s; +} + +.bar-die-slot { + display: flex; + align-items: center; + justify-content: center; +} + +.die-face.die-double rect { stroke: var(--ui-gold); stroke-width: 2.5; } +.die-face.die-double { + filter: drop-shadow(0 0 6px rgba(200,164,72,0.7)) drop-shadow(0 2px 3px rgba(0,0,0,0.3)); +} + +.die-face.die-used { animation: none; opacity: 0.55; } +.die-face.die-used rect { fill: #d4d0c4; stroke: #9a8a70; } +.die-face.die-used circle { fill: #9a8a70; } + +.die-face .die-question { fill: #1a0a00; font-family: sans-serif; } +.die-face.die-used .die-question { fill: #9a8a70; } + +/* ── Jan panel ──────────────────────────────────────────────────────── */ +.jan-panel { + display: flex; + flex-direction: column; + gap: 2px; + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.4rem 0.9rem; + font-size: 0.88rem; + box-shadow: 0 1px 4px rgba(0,0,0,0.15); + min-width: 260px; + border-top: 2px solid rgba(138,106,40,0.35); +} + +.jan-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 2px 4px; + border-radius: 3px; +} +.jan-expandable { cursor: pointer; } +.jan-expandable:hover { background: rgba(0,0,0,0.05); } + +.jan-positive { color: #1a5c1a; } +.jan-negative { color: #8b1a1a; } + +.jan-label { flex: 1; } +.jan-tag { + font-size: 0.72rem; + padding: 0.1em 0.4em; + border-radius: 3px; + background: rgba(0,0,0,0.07); + color: #665544; + white-space: nowrap; +} +.jan-pts { font-weight: 600; text-align: right; min-width: 3rem; } + +.jan-moves { padding: 1px 4px 4px 1rem; display: flex; flex-direction: column; gap: 2px; } +.jan-moves.hidden { display: none; } +.jan-move-line { font-family: monospace; font-size: 0.78rem; color: #555; } + +/* ── Game-over overlay (§12) ────────────────────────────────────────── */ +.game-over-overlay { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +@keyframes game-over-appear { + from { transform: translateY(-24px) scale(0.94); opacity: 0; } + to { transform: translateY(0) scale(1); opacity: 1; } +} + +.game-over-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + gap: 1.1rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.game-over-box h2 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.game-over-winner { + font-family: var(--font-display); + font-size: 1.25rem; + color: var(--ui-green-accent); + font-style: italic; +} + +.game-over-score { + display: flex; + align-items: center; + justify-content: center; + gap: 0.75rem; + padding: 0.6rem 1rem; + background: rgba(0,0,0,0.05); + border-radius: 5px; + border: 1px solid rgba(138,106,40,0.2); +} + +.game-over-score-name { + font-family: var(--font-display); + font-size: 0.9rem; + color: #665544; + font-style: italic; + max-width: 80px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.game-over-score-nums { + font-family: var(--font-display); + font-size: 2.25rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.08em; + line-height: 1; +} + +.game-over-actions { display: flex; gap: 0.75rem; justify-content: center; } + +/* ── Score-area: position:relative wrapper for merged panel + scoring ── */ +.score-area { + position: relative; + width: 100%; +} + +/* ── Scoring panels container — right of the hole counter ───────────── */ +/* Stacked column, right-aligned, covering the free space in each row. */ +/* overflow:visible lets tall panels float over the board below. */ +.scoring-panels-container { + position: absolute; + top: 0; + bottom: 0; + right: 0; + display: flex; + flex-direction: column; + justify-content: space-around; + align-items: flex-end; + padding: 4px 8px; + z-index: 10; + pointer-events: none; + overflow: visible; +} + +/* ── Scoring notification panel (§6b) ───────────────────────────────── */ +@keyframes scoring-panel-enter { + from { opacity: 0; transform: translateX(10px); } + to { opacity: 1; transform: translateX(0); } +} + +.scoring-panel-wrapper { + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 3px; + animation: scoring-panel-enter 0.3s ease-out; +} + +/* "+" expand button: hidden while the panel is expanded */ +.scoring-panel-wrapper:not(.scoring-minimized) .scoring-expand-btn { + display: none; +} + +/* Full panel card: hidden once minimised */ +.scoring-panel-wrapper.scoring-minimized .scoring-panel { + display: none; +} + +/* "+" expand button ─────────────────────────────────────────────────── */ +.scoring-expand-btn { + font-family: var(--font-display); + font-size: 0.9rem; + line-height: 1; + background: var(--ui-parchment); + border: 1.5px solid var(--ui-gold-dark); + border-radius: 3px; + padding: 2px 7px; + cursor: pointer; + color: var(--ui-ink); + opacity: 0.72; + box-shadow: 0 1px 3px rgba(0,0,0,0.18); + transition: opacity 0.15s; +} +.scoring-expand-btn:hover { opacity: 1; } + +/* ── Panel head: scoring total + "−" collapse link ──────────────────── */ +.scoring-panel-head { + display: flex; + align-items: baseline; + gap: 0.5rem; +} + +.scoring-collapse-btn { + font-size: 0.78rem; + line-height: 1; + background: none; + border: none; + cursor: pointer; + color: rgba(0,0,0,0.35); + padding: 0 1px; + margin-left: auto; + flex-shrink: 0; + transition: color 0.15s; +} +.scoring-collapse-btn:hover { color: rgba(0,0,0,0.65); } + +/* ── Inner scoring card ─────────────────────────────────────────────── */ +.scoring-panel { + background: var(--ui-parchment); + border-radius: 5px; + padding: 0.45rem 0.85rem; + font-size: 0.84rem; + box-shadow: 0 2px 8px rgba(0,0,0,0.22); + border-left: 3px solid var(--ui-green-accent); + display: flex; + flex-direction: column; + gap: 4px; + width: 320px; +} + +.scoring-total { + font-family: var(--font-display); + font-weight: 600; + font-size: 1rem; + color: #1a5c1a; + white-space: nowrap; +} + +.scoring-jan-row { + display: flex; + align-items: center; + gap: 0.4rem; + padding: 2px 3px; + border-radius: 3px; + cursor: default; + white-space: nowrap; +} +.scoring-jan-row:hover { background: rgba(0,0,0,0.05); } + +.scoring-panel-opp { border-left-color: var(--board-rail); } +.scoring-panel-opp .scoring-total { color: var(--ui-red-accent); } + +.scoring-hole { + display: flex; + align-items: center; + gap: 0.4rem; + font-weight: 600; + color: var(--ui-red-accent); + margin-top: 3px; + padding-top: 4px; + border-top: 1px solid rgba(0,0,0,0.1); +} + +.hold-go-buttons { display: flex; gap: 0.5rem; margin-top: 4px; } + +@media (min-width: 1492px) { + .side-panel { + right: auto; + left: calc(100% + 1rem); + } +} + +/* ── Board wrapper ──────────────────────────────────────────────────── */ +.board-wrapper { + display: flex; + flex-direction: column; + gap: 3px; +} + +/* ── Zone labels (§2a) ──────────────────────────────────────────────── */ +.zone-labels-row { + display: flex; + gap: 4px; + padding: 0 8px; +} + +.zone-label { + font-family: var(--font-display); + font-size: 0.57rem; + font-style: italic; + color: rgba(240,228,192,0.48); + letter-spacing: 0.1em; + text-align: center; + pointer-events: none; + line-height: 1; +} + +.zone-label-quarter { width: 370px; flex-shrink: 0; } +.zone-label-bar { width: 68px; flex-shrink: 0; } + +/* ── Board ──────────────────────────────────────────────────────────── */ +.board { + background: var(--board-felt); + border: 4px solid var(--board-rail); + border-radius: 6px; + padding: 4px; + display: flex; + flex-direction: column; + gap: 4px; + user-select: none; + box-shadow: + 0 6px 16px rgba(0,0,0,0.5), + inset 0 1px 0 rgba(255,255,255,0.04); + position: relative; +} + +.board-row { display: flex; gap: 4px; } +.board-quarter { display: flex; gap: 2px; } + +.board-bar { + width: 68px; + background: var(--board-rail); + border-radius: 4px; + box-shadow: inset 0 0 6px rgba(0,0,0,0.5); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + overflow: visible; +} + +.board-center-bar { + height: 12px; + background: var(--board-rail); + border-radius: 2px; + box-shadow: inset 0 0 4px rgba(0,0,0,0.4); +} + +/* ── Fields (§1) ────────────────────────────────────────────────────── */ +.field { + --fc: var(--field-ivory); + width: 60px; + height: 180px; + background: transparent; + isolation: isolate; + border-radius: 3px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + padding: 4px 2px; + position: relative; +} + +.field::before { + content: ''; + position: absolute; + inset: 0; + z-index: -1; + background: var(--fc); + clip-path: polygon(0% 100%, 50% 0%, 100% 100%); + transition: background 0.12s; +} + +.top-row .field::before { + clip-path: polygon(0% 0%, 100% 0%, 50% 100%); +} + +.top-row .field { justify-content: flex-start; } + +/* ── Zone alternating colours ────────────────────────────────── */ +.board-quarter .field.zone-petit:nth-child(odd), +.board-quarter .field.zone-grand:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-petit:nth-child(even), +.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } + +.board-quarter .field.zone-opponent:nth-child(odd), +.board-quarter .field.zone-retour:nth-child(odd) { --fc: var(--field-burgundy); } +.board-quarter .field.zone-opponent:nth-child(even), +.board-quarter .field.zone-retour:nth-child(even) { --fc: var(--field-ivory); } + +/* ── Point indicator: first N fields reflect each player's score & bredouille */ +.board-quarter .field.zone-petit.point-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-petit.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-grand.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(odd), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(odd) { --fc: var(--field-blue-light); } +.board-quarter .field.zone-opponent.point-no-bredouille:nth-child(even), +.board-quarter .field.zone-retour.point-no-bredouille:nth-child(even) { --fc: var(--field-blue); } + +.field.corner::after { + content: '♛'; + position: absolute; + z-index: -1; + bottom: 22px; + font-size: 0.7rem; + color: rgba(255,248,210,0.38); + pointer-events: none; + line-height: 1; +} +.top-row .field.corner::after { bottom: auto; top: 22px; } + +@keyframes corner-pulse { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(200,164,72,0)); } + 50% { filter: drop-shadow(0 0 7px rgba(200,164,72,0.55)); } +} +.field.corner.corner-available { + animation: corner-pulse 1.5s ease-in-out infinite; +} + +@keyframes exit-glow { + 0%, 100% { filter: drop-shadow(0 0 0px rgba(232,192,96,0)); } + 50% { filter: drop-shadow(0 0 5px rgba(232,192,96,0.5)); } +} +.field.exit-eligible { + animation: exit-glow 2s ease-in-out infinite; +} + +/* ── Exit sign (§8c) — circle+arrow outside the board ──────────────── */ +.exit-btn { + pointer-events: none; + opacity: 0.3; + transition: opacity 0.2s, transform 0.15s; +} +.exit-btn.exit-active { + pointer-events: auto; + cursor: pointer; + opacity: 1; + animation: exit-btn-pulse 1.4s ease-in-out infinite; +} +.exit-btn.exit-active:hover { + transform: scale(1.1); +} +@keyframes exit-btn-pulse { + 0%, 100% { filter: drop-shadow(0 0 3px rgba(200,160,20,0.3)); } + 50% { filter: drop-shadow(0 0 9px rgba(200,160,20,0.85)); } +} + +.field.jan-hovered { + --fc: rgba(190, 140, 35, 0.8) !important; +} + +@keyframes hit-ripple { + from { transform: translate(-50%, -50%) scale(0.4); opacity: 0.9; } + to { transform: translate(-50%, -50%) scale(2.2); opacity: 0; } +} +.hit-ripple { + position: absolute; + left: 50%; + width: 36px; + height: 36px; + border-radius: 50%; + border: 2px solid rgba(200, 164, 72, 0.9); + pointer-events: none; + animation: hit-ripple 0.5s ease-out forwards; +} +.hit-ripple-top { top: 26px; } +.hit-ripple-bot { bottom: 26px; } + +.field.clickable { + cursor: pointer; +} +.field.clickable:hover { + --fc: rgba(200,170,50,0.18) !important; +} +.field.selected { + /* natural triangle color; tab is the indicator */ +} + +/* ── Tab indicators: small markers at the field's wide base ──────── */ +/* Bot-row: tabs hang below; top-row: tabs hang above. */ +/* The tab sits at ≈ -6px which lands on the board's wooden rail. */ + +.field.clickable::after, +.field.selected::after { + content: ''; + position: absolute; + left: 50%; + transform: translateX(-50%); + width: 22px; + height: 8px; + pointer-events: none; + z-index: 2; +} + +.bot-row .field.clickable::after, +.bot-row .field.selected::after { + bottom: -6px; + top: auto; + border-radius: 0 0 10px 10px; +} +.top-row .field.clickable::after, +.top-row .field.selected::after { + top: -6px; + bottom: auto; + border-radius: 10px 10px 0 0; +} + +/* Possible origin: hollow gold outline */ +.field.clickable:not(.dest):not(.selected)::after { + background: rgba(210,170,30,0.15); + border: 1.5px solid rgba(210,170,30,0.75); + box-shadow: 0 0 4px rgba(210,170,30,0.3); +} + +/* Selected origin: filled amber, breathing glow */ +.field.selected::after { + background: linear-gradient(to bottom, #e8b020, #c07808); + border: 1px solid rgba(255,225,65,0.55); + animation: tab-pulse 1.2s ease-in-out infinite; +} + +@keyframes tab-pulse { + 0%, 100% { box-shadow: 0 0 5px rgba(220,155,15,0.55), 0 0 2px rgba(255,220,50,0.3); } + 50% { box-shadow: 0 0 13px rgba(240,178,22,0.88), 0 0 6px rgba(255,230,60,0.6); } +} + +/* Valid destination: soft ivory/pearl */ +.field.clickable.dest:not(.selected)::after { + background: rgba(240,230,205,0.88); + border: 1.5px solid rgba(190,165,105,0.65); + box-shadow: 0 0 3px rgba(190,165,105,0.2); +} +.field.clickable.dest:not(.selected):hover::after { + background: rgba(228,210,162,0.95); + border-color: rgba(210,175,40,0.72); + box-shadow: 0 0 7px rgba(210,175,40,0.42); +} + +.field-num { + font-size: 0.58rem; + color: rgba(0,0,0,0.28); + position: absolute; + bottom: 3px; + line-height: 1; + font-variant-numeric: tabular-nums; +} + +.board-quarter .field.zone-petit:nth-child(odd) .field-num, +.board-quarter .field.zone-grand:nth-child(odd) .field-num, +.board-quarter .field.zone-retour:nth-child(odd) .field-num, +.board-quarter .field.zone-opponent:nth-child(odd) .field-num { + color: rgba(240,215,190,0.38); +} + +.field.corner .field-num { color: rgba(255,248,200,0.4); } +.top-row .field-num { bottom: auto; top: 3px; } + +/* ── Checkers ───────────────────────────────────────────────────────── */ +.checker-stack { + display: flex; + flex-direction: column; + align-items: center; +} + +.checker { + width: 40px; + height: 40px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.78rem; + font-weight: 600; + flex-shrink: 0; +} + +.checker + .checker { margin-top: -4px; } + +.checker.white { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, + rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(160,130,70,0.22) 68.5%, + rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(160,130,70,0.17) 43.5%, + rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.4), + inset 0 -1px 3px rgba(0,0,0,0.15); + color: #443322; +} + +.checker.black { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, + rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, + transparent 68%, rgba(200,164,72,0.18) 68.5%, + rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, + transparent 43%, rgba(200,164,72,0.13) 43.5%, + rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, + #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: + 0 2px 6px rgba(0,0,0,0.55), + inset 0 -1px 3px rgba(0,0,0,0.4); + color: #c8b898; +} + +/* ── Hole toast (§6a) ───────────────────────────────────────────────── */ +@keyframes toast-rise { + from { transform: translate(-50%, -40%); opacity: 0; } + to { transform: translate(-50%, -50%); opacity: 1; } +} +@keyframes toast-fade { + from { opacity: 1; } + to { opacity: 0; pointer-events: none; } +} + +.hole-toast { + position: fixed; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + background: rgba(22,10,2,0.93); + border: 2px solid var(--ui-gold); + border-radius: 8px; + padding: 1.5rem 3.5rem; + text-align: center; + z-index: 50; + pointer-events: none; + box-shadow: + 0 12px 40px rgba(0,0,0,0.65), + 0 0 0 1px rgba(200,164,72,0.25), + inset 0 1px 0 rgba(200,164,72,0.1); + animation: + toast-rise 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), + toast-fade 0.5s ease-in 1.4s forwards; +} + +.hole-toast-title { + font-family: var(--font-display); + font-size: 3.25rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.1em; + line-height: 1; +} + +.hole-toast-count { + font-family: var(--font-display); + font-size: 1.1rem; + color: rgba(200,164,72,0.68); + margin-top: 0.35rem; + letter-spacing: 0.06em; +} + +.hole-toast-bredouille { + font-family: var(--font-ui); + font-size: 0.75rem; + letter-spacing: 0.08em; + color: rgba(200,164,72,0.55); + margin-top: 0.4rem; + text-transform: uppercase; +} + +@keyframes bredouille-shimmer { + 0%, 100% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 2px rgba(200,164,72,0.4), inset 0 0 0 rgba(200,164,72,0); } + 50% { box-shadow: 0 12px 40px rgba(0,0,0,0.65), 0 0 0 4px rgba(200,164,72,0.7), inset 0 0 24px rgba(200,164,72,0.08); } +} +.hole-toast.hole-toast-bredouille { + border-width: 2.5px; + border-color: var(--ui-gold); + padding: 2rem 4rem; + animation: + toast-rise 0.3s cubic-bezier(0.22, 0.61, 0.36, 1), + bredouille-shimmer 0.9s ease-in-out 0.3s 2, + toast-fade 0.5s ease-in 2.2s forwards; +} +.hole-toast.hole-toast-bredouille .hole-toast-title { font-size: 3.75rem; } +.hole-toast.hole-toast-bredouille .hole-toast-bredouille { + font-size: 0.85rem; + color: rgba(200,164,72,0.8); + letter-spacing: 0.14em; +} + +/* ── Checker slide animation (§4a) ─────────────────────────────────── */ +@keyframes checker-slide-in { + from { transform: translate(var(--slide-dx, 0px), var(--slide-dy, 0px)); } + to { transform: none; } +} +.checker.arriving { + animation: checker-slide-in 0.28s cubic-bezier(0.25, 0.46, 0.45, 0.94); +} +.field:has(.checker.arriving) { + isolation: auto; + z-index: 10; + position: relative; +} + +/* ── Checker lift on selected field (§4b) ───────────────────────────── */ +.field.selected .checker-stack { + transform: translateY(-5px); + filter: drop-shadow(0 8px 12px rgba(0,0,0,0.6)); + transition: transform 0.12s ease-out, filter 0.12s ease-out; +} + +/* ── Action buttons below board (§10c) ──────────────────────────────── */ +.board-actions { + display: flex; + gap: 0.55rem; + justify-content: center; + align-items: center; + flex-wrap: wrap; + 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; + inset: 0; + background: rgba(0,0,0,0.65); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.ceremony-box { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2.5rem 3rem; + text-align: center; + box-shadow: 0 12px 40px rgba(0,0,0,0.5), 0 0 0 2px var(--ui-gold-dark); + display: flex; + flex-direction: column; + align-items: center; + gap: 1.4rem; + min-width: 300px; + animation: game-over-appear 0.4s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.ceremony-box h2 { + font-family: var(--font-display); + font-size: 1.8rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.06em; +} + +.ceremony-dice { + display: flex; + gap: 3rem; + align-items: flex-end; +} + +.ceremony-die-slot { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; +} + +.ceremony-die-label { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-ink); + font-weight: 500; +} + +.ceremony-tie { + font-family: var(--font-display); + font-size: 1rem; + color: var(--ui-red-accent); + font-style: italic; +} + +.ceremony-result { + font-family: var(--font-display); + font-size: 1.15rem; + font-weight: 600; + color: var(--ui-gold-dark); + letter-spacing: 0.04em; +} + +/* ── Nickname modal (anonymous player name chooser) ─────────────────── */ +.nickname-backdrop { + position: fixed; + inset: 0; + background: rgba(0,0,0,0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 300; +} + +.nickname-modal { + background: var(--ui-parchment); + border-radius: 8px; + padding: 2rem 2rem 1.75rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 1rem; + box-shadow: + 0 20px 60px rgba(0,0,0,0.55), + 0 0 0 1px rgba(200,164,72,0.35), + 0 0 0 5px rgba(42,21,8,0.9), + 0 0 0 6px rgba(200,164,72,0.2); + animation: game-over-appear 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); +} + +.nickname-modal-title { + font-family: var(--font-display); + font-size: 1.5rem; + font-weight: 600; + color: var(--ui-ink); + text-align: center; + letter-spacing: 0.04em; +} + +.nickname-modal-hint { + font-family: var(--font-ui); + font-size: 0.8rem; + color: rgba(42,26,8,0.6); + text-align: center; + margin-bottom: -0.25rem; +} + +.nickname-modal-alt { + text-align: center; + font-size: 0.8rem; + color: rgba(42,26,8,0.55); + padding-top: 0.5rem; + border-top: 1px solid rgba(138,106,40,0.2); +} + +.nickname-modal-alt a { + color: var(--ui-gold-dark); + text-decoration: none; + font-weight: 500; +} + +.nickname-modal-alt a:hover { text-decoration: underline; } + +/* ── Game hamburger button (☰ → ✕ animation) ────────────────────────── */ +.game-hamburger { + position: fixed; + top: 0.6rem; + left: 0.6rem; + z-index: 251; + width: 36px; + height: 36px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 5px; + background: var(--board-rail); + border: 1px solid rgba(200,164,72,0.35); + border-radius: 5px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} +.game-hamburger:hover { + background: #3d1f0a; + border-color: rgba(200,164,72,0.65); +} + +.hb-bar { + display: block; + width: 16px; + height: 2px; + background: var(--ui-parchment); + border-radius: 1px; + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1), opacity 0.2s; + transform-origin: center; +} +/* Top bar rotates down to form \ */ +.game-hamburger-open .hb-top { transform: translateY(7px) rotate(45deg); } +/* Middle bar fades out */ +.game-hamburger-open .hb-mid { opacity: 0; transform: scaleX(0); } +/* Bottom bar rotates up to form / */ +.game-hamburger-open .hb-bot { transform: translateY(-7px) rotate(-45deg); } + +/* ── Game sidebar ────────────────────────────────────────────────────── */ +.game-sidebar { + position: fixed; + top: 0; + left: 0; + height: 100vh; + width: 280px; + z-index: 250; + background: var(--board-rail); + border-right: 1px solid rgba(200,164,72,0.25); + display: flex; + flex-direction: column; + transform: translateX(-100%); + transition: transform 0.25s cubic-bezier(0.22, 0.61, 0.36, 1); + overflow-y: auto; +} +.game-sidebar-open { + transform: translateX(0); +} + +.game-sidebar-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.2); + flex-shrink: 0; +} + +.game-sidebar-brand { + font-family: var(--font-display); + font-size: 1.3rem; + font-weight: 600; + color: var(--ui-gold); + letter-spacing: 0.06em; + margin-left: 45px; +} + +.game-sidebar-close { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: 1px solid rgba(200,164,72,0.25); + border-radius: 4px; + color: var(--ui-parchment); + font-size: 0.85rem; + cursor: pointer; + opacity: 0.65; + transition: opacity 0.15s; +} +.game-sidebar-close:hover { opacity: 1; } + +.game-sidebar-section { + padding: 0.9rem 1rem; + border-bottom: 1px solid rgba(200,164,72,0.12); + display: flex; + flex-direction: row; + gap: 0.55rem; +} + +.game-sidebar-label { + font-size: 0.7rem; + font-family: var(--font-ui); + letter-spacing: 0.07em; + text-transform: uppercase; + color: rgba(242,232,208,0.45); +} + +.game-sidebar-link { + font-family: var(--font-ui); + font-size: 0.85rem; + color: var(--ui-parchment); + text-decoration: none; + opacity: 0.8; + transition: opacity 0.15s; + cursor: pointer; +} +.game-sidebar-link:hover { opacity: 1; text-decoration: underline; text-underline-offset: 2px; } + +.game-sidebar-btn { + font-family: var(--font-ui); + font-size: 0.82rem; + padding: 0.4rem 0.75rem; + border: 1px solid rgba(200,164,72,0.35); + border-radius: 4px; + background: rgba(200,164,72,0.1); + color: var(--ui-parchment); + cursor: pointer; + text-align: left; + transition: background 0.15s; +} +.game-sidebar-btn:hover { background: rgba(200,164,72,0.22); } + +.game-sidebar-btn-newgame { + background: rgba(58,107,42,0.25); + border-color: rgba(58,107,42,0.55); + font-weight: 500; +} +.game-sidebar-btn-newgame:hover { background: rgba(58,107,42,0.42); } + +.game-sidebar-qr { + width: 100%; + height: auto; + aspect-ratio: 1; + max-width: 200px; + margin: 0 auto; +} + +/* Push the version wrapper to the bottom of the sidebar flex column */ +.sidebar-footer { + margin-top: auto; + border-top: 1px solid rgba(200,164,72,0.12); +} + +.site-nav-infolinks { + margin: 2em 0 1em; + text-align: center; + font-size: 0.9rem; + color: rgba(200,164,72,0.4); + display: flex; + flex-direction: row; + align-items: center; +} + +.site-nav-infolinks > a { + width: 100%; +} + +.site-nav-version { + margin: 2em 0 1em; + display: block; + text-align: center; + font-family: var(--font-ui); + font-size: 0.7rem; + letter-spacing: 0.06em; + color: rgba(200,164,72,0.4); +} + +/* ── Content pages (markdown-rendered) ─────────────────────────────────────── */ + +.content-page h1 { + font-family: var(--font-display); + font-size: 2rem; + font-weight: 600; + color: var(--ui-ink); + letter-spacing: 0.04em; + margin-bottom: 0.5rem; +} +.content-page h2 { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.75rem 0 0.5rem; + border-bottom: 1px solid rgba(200,164,72,0.25); + padding-bottom: 0.25rem; +} +.content-page h3 { + font-family: var(--font-display); + font-size: 1.1rem; + font-weight: 600; + color: var(--ui-ink); + margin: 1.25rem 0 0.4rem; +} +.content-page p { + line-height: 1.7; + margin-bottom: 0.9rem; + color: var(--ui-ink); +} +.content-page ul, +.content-page ol { + margin: 0.5rem 0 1rem 1.5rem; + line-height: 1.7; + color: var(--ui-ink); +} +.content-page li { + margin-bottom: 0.25rem; +} +.content-page a { + color: var(--ui-gold-dark); + text-decoration: underline; +} +.content-page a:hover { + color: var(--ui-ink); +} +.content-page code { + font-family: monospace; + background: rgba(0,0,0,0.07); + border-radius: 3px; + padding: 0.1em 0.35em; + font-size: 0.88em; +} +.content-page pre { + background: rgba(0,0,0,0.07); + border-radius: 5px; + padding: 1rem 1.25rem; + overflow-x: auto; + margin-bottom: 1rem; +} +.content-page pre code { + background: none; + padding: 0; +} +.content-page blockquote { + border-left: 3px solid rgba(200,164,72,0.5); + margin: 0.75rem 0; + padding: 0.25rem 1rem; + color: #665544; + font-style: italic; +} +.content-page table { + border-collapse: collapse; + width: 100%; + margin-bottom: 1rem; +} +.content-page th, +.content-page td { + border: 1px solid rgba(200,164,72,0.3); + padding: 0.4rem 0.75rem; + text-align: left; +} +.content-page th { + background: rgba(200,164,72,0.1); + font-weight: 600; +} + +/* ══════════════════════════════════════════════════════════════════════ + Layout variation 07 — scrolling strip + sidebar controls + ══════════════════════════════════════════════════════════════════════ */ + +/* Prevent horizontal scrollbar from the full-bleed strip */ +.game-overlay { overflow-x: hidden !important; } + +/* Board bar: hide die slots, keep the rail as a thin divider */ +.bar-die-slot { display: none !important; } +.board-bar { width: 5px; overflow: hidden; } + +/* ── Full-width in-flow player strip ─────────────────────────────────── */ +.v07-strip { + width: 100vw; + margin-top: -1.5rem; /* undo game-overlay top padding */ + margin-left: calc(50% - 50vw); /* align to viewport left */ + display: flex; + align-items: center; + background: var(--ui-parchment); + border-bottom: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 8px rgba(0,0,0,0.18); + padding: 0.35rem 1.5rem; + gap: 0.5rem; +} + +.v07-player { display: flex; align-items: center; flex: 1; min-width: 0; } +.v07-player-left { justify-content: flex-end; } +.v07-player-right { justify-content: flex-start; } + +.v07-active-zone { + display: flex; + align-items: center; + gap: 0.7rem; + border-radius: 8px; + padding: 0.28rem 0.5rem; + transition: background 0.15s; +} +.v07-active-zone.active { background: rgba(58,42,10,0.08); } + +/* Checker-style circles */ +.v07-avatar { + width: 38px; height: 38px; + border-radius: 50%; + flex-shrink: 0; +} +.v07-avatar-me { + background-image: + radial-gradient(ellipse 50% 35% at 36% 30%, rgba(255,255,255,0.65) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(160,130,70,0.22) 68.5%, rgba(160,130,70,0.22) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(160,130,70,0.17) 43.5%, rgba(160,130,70,0.17) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #ffffff 0%, var(--checker-white) 52%, #c0b288 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.35), inset 0 -1px 3px rgba(0,0,0,0.15); +} +.v07-avatar-opp { + background-image: + radial-gradient(ellipse 40% 28% at 36% 30%, rgba(110,65,30,0.38) 0%, transparent 100%), + radial-gradient(circle, transparent 68%, rgba(200,164,72,0.18) 68.5%, rgba(200,164,72,0.18) 71.5%, transparent 72%), + radial-gradient(circle, transparent 43%, rgba(200,164,72,0.13) 43.5%, rgba(200,164,72,0.13) 46.5%, transparent 47%), + radial-gradient(circle at 38% 32%, #4a2e1a 0%, #1c1008 45%, var(--checker-black) 100%); + border: 1.8px solid var(--checker-ring); + box-shadow: 0 2px 6px rgba(0,0,0,0.5), inset 0 -1px 3px rgba(0,0,0,0.4); +} + +/* Strip peg overrides */ +.v07-strip .peg-track { gap: 3px; } +.v07-strip .peg-hole { width: 12px; height: 12px; } +.v07-strip .peg-hole.filled { + background: #5aab38; border-color: #3a7828; + box-shadow: 0 0 5px rgba(90,171,56,0.55); +} +.v07-strip .peg-hole.peg-opp.filled { + background: #c05030; border-color: #8a3018; + box-shadow: 0 0 5px rgba(192,80,48,0.55); +} + +/* Strip score-row-name: remove fixed width from v01 */ +.v07-strip .score-row-name { width: auto; } + +/* No ghost bar below pts-counter in the strip */ +.v07-strip .pts-counter-wrap { padding-bottom: 0; } + +/* Center "Trictrac" title */ +.v07-strip-center { + flex-shrink: 0; + padding: 0 1rem; + border-left: 1px solid rgba(138,106,40,0.2); + border-right: 1px solid rgba(138,106,40,0.2); +} +.v07-title { + font-family: var(--font-display); + font-size: 1.4rem; + font-weight: 600; + font-style: italic; + color: var(--ui-ink); + letter-spacing: 0.03em; + white-space: nowrap; +} + +/* ── Body: board + controls ──────────────────────────────────────────── */ +.v07-body { + display: flex; + align-items: flex-start; + gap: 0.5rem; + width: 100%; +} + +/* ── Controls column (sidebar on wide, row on narrow) ────────────────── */ +.v07-controls { + display: flex; + flex-direction: column; + gap: 0.5rem; + width: 152px; + flex-shrink: 0; + align-self: stretch; +} + +.v07-ctrl-dice { + background: var(--board-rail); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.6rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.5rem; + flex-shrink: 0; +} +.v07-ctrl-dice-row { + display: flex; + gap: 0.55rem; + align-items: center; + justify-content: center; +} + +/* Free-mode toggle: light text on dark board-rail background */ +.v07-ctrl-dice .free-mode-toggle { + color: var(--ui-parchment); + font-size: 0.7rem; + flex-wrap: wrap; + justify-content: center; + text-align: center; + gap: 0.3rem; +} +.v07-ctrl-dice .free-mode-help { + border-color: rgba(242,232,208,0.35); + color: rgba(242,232,208,0.5); +} + +.v07-ctrl-status { + background: var(--ui-parchment); + border-radius: 5px; + border-top: 2px solid var(--ui-gold-dark); + box-shadow: 0 2px 6px rgba(0,0,0,0.2); + padding: 0.65rem 0.75rem 0.75rem; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.4rem; + flex: 1; + min-width: 0; +} +.v07-ctrl-status .game-status { + color: var(--ui-ink); + text-shadow: none; + font-size: 1rem; + padding: 0; + width: auto; + text-align: center; + line-height: 1.3; +} +.v07-ctrl-status .board-actions { + flex-wrap: wrap; + justify-content: center; + min-height: 0; +} +.v07-ctrl-status .game-sub-prompt { + color: #887766; + padding: 0; + width: auto; + text-align: center; + font-size: 0.67rem; + line-height: 1.4; + margin: 0; +} + +/* ── Scoring panels row (below board+controls, in-flow) ──────────────── */ +.v07-scoring-row { width: 100%; } + +/* Reset absolute positioning from the old score-area context */ +.v07-scoring-row .scoring-panels-container { + position: static; + top: auto; left: auto; right: auto; bottom: auto; + z-index: auto; + padding: 0; + pointer-events: auto; + display: flex; + flex-direction: column; + align-items: stretch; + gap: 4px; +} +.v07-scoring-row .scoring-panel { + width: 100%; + box-sizing: border-box; + margin: 0; +} + +/* ── Responsive: ≤919px → controls becomes a bottom bar ─────────────── */ +@media (max-width: 919px) { + .v07-body { + flex-direction: column; + align-items: stretch; + } + .v07-controls { + flex-direction: row; + width: 100%; + } + .v07-ctrl-status { flex: 1; } + /* Hide pegs on small screens to save space in the strip */ + .v07-strip .peg-track { display: none; } +} diff --git a/doc/design/variations/01-dice-sidebar.html b/doc/design/variations/01-dice-sidebar.html new file mode 100644 index 0000000..7d0222d --- /dev/null +++ b/doc/design/variations/01-dice-sidebar.html @@ -0,0 +1,418 @@ + + + + + + Variation 01 — Dés en sidebar droite + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ Anonyme + (vous) +
+
+
+
+
+
+ 6 + /12 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Bot +
+
+
+
+
+
+ 2 + /12 +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ + +
+ + +
+ +
+ + +
+ +
+
+ 13 +
+
+ 14 +
+
+ 15 +
+
+
+
+
+
+
+ 16 +
+
+ 17 +
+
+ 18 +
+
+ + +
+ +
+
+ 19 +
+
+
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
+
+
11
+
+
+
+ +
+ +
+ + +
+ +
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+
+
+
+ 7 +
+
+
+
+
+
+ + +
+ +
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+
+ +
+ + + +
+ +
+ + +
+ + + + + + + +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/02-horizontal-header.html b/doc/design/variations/02-horizontal-header.html new file mode 100644 index 0000000..09502a5 --- /dev/null +++ b/doc/design/variations/02-horizontal-header.html @@ -0,0 +1,628 @@ + + + + + + Variation 02 — En-tête horizontal · Scoring latéral + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + à vous de jouer +
+ + +
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+ +
Aucun événement de marque
+
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + + + + +
+ +
+ + +
+
+ + + + + + + + + + + + + + + + +
+
+
Déplacez une dame (1 sur 2)
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/03-dark-modern.html b/doc/design/variations/03-dark-modern.html new file mode 100644 index 0000000..ce480c0 --- /dev/null +++ b/doc/design/variations/03-dark-modern.html @@ -0,0 +1,655 @@ + + + + + + Variation 03 — Dark Modern · Dock de contrôle + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
A
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/04-warm-bottom-scoring.html b/doc/design/variations/04-warm-bottom-scoring.html new file mode 100644 index 0000000..d92a2d3 --- /dev/null +++ b/doc/design/variations/04-warm-bottom-scoring.html @@ -0,0 +1,620 @@ + + + + + + Variation 04 — Warm Classic · Scoring en bas + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
A
+
+ Anonyme + (vous) +
+
+
+
+
+
+
+
+
+
+ +
+ 6 + /12 +
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+ + + + +
+ + +
+ + +
+ + + + + + + + + + + + + + + + + + +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ + +
+ +
+ +
+ +
+
+ + + diff --git a/doc/design/variations/05-warm-sticky-header.html b/doc/design/variations/05-warm-sticky-header.html new file mode 100644 index 0000000..6d661a7 --- /dev/null +++ b/doc/design/variations/05-warm-sticky-header.html @@ -0,0 +1,582 @@ + + + + + + Variation 05 — Warm · Sticky header · Scoring below dock + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ 6 + /12 +
+ +
+
+
+
+
+
+
+
+
+
+ Anonyme + (vous) +
+
A
+
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+
B
+
+ Bot +
+
+
+
+
+
+
+
+
+
+
+ 2 + /12 +
+
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + + + +
+ +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/06-wide-header.html b/doc/design/variations/06-wide-header.html new file mode 100644 index 0000000..9a9ed8f --- /dev/null +++ b/doc/design/variations/06-wide-header.html @@ -0,0 +1,571 @@ + + + + + + Variation 06 — Wide fixed header · Scoring below dock + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+ + +
A
+ + +
+ Anonyme + (vous) +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+ 6 + /12 +
+
+ +
+ + +
+ VS + jeu en 12 trous + votre tour +
+ + +
+ + +
+
+
+
+
+ 2 + /12 +
+
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + +
+ Bot +
+ + +
B
+ +
+ +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ + +
+
+ + + + + + + + + + + + + + + + +
+ +
+ + +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/design/variations/07-scrolling-header.html b/doc/design/variations/07-scrolling-header.html new file mode 100644 index 0000000..122e772 --- /dev/null +++ b/doc/design/variations/07-scrolling-header.html @@ -0,0 +1,711 @@ + + + + + + Variation 07 — Scrolling header · Responsive sidebar/footer + + + + + + + + +
+
+ Trictrac +
+ + +
+
+ +
+ + Se connecter +
+ +
+ + +
+
+ +
+
+ + +
+
+ + +
+ + +
+
+ +
A
+ +
+ Anonyme + (vous) +
+ +
+
+
+
+
+
+
+
+
+
+
+ +
+
+ 6 + /12 +
+
+
+
+ + +
+ Trictrac +
+ + +
+
+ +
+
+ 2 + /12 +
+
+ +
+
+
+
+
+
+
+
+
+ +
+ Bot +
+ +
B
+
+
+ +
+ + +
+ + +
+
+ + +
+
+
13
+
14
+
+ 15 +
+
+
+
+
16
+
17
+
18
+
+
+
+
+ 19 +
+
+
+
20
+
21
+
22
+
23
+
+ 24 +
+
+
11
+
+
+
+
+ +
+ + +
+
+
+ 12 +
+
11
+
10
+
+ 9 +
+
+
+ 8 +
+
+
+ 7 +
+
+
+
+
+
6
+
5
+
4
+
3
+
2
+
+ 1 +
+
10
+
+
+
+
+
+ + + +
+
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
+ +
+ +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ +
+ + +
+ +
+
+ + + + + + + + + + + + + + + + +
+ +
+ +
+
Déplacez une dame (1 sur 2)
+
+ +
+
+ +
+ + +
+
+
+ +
+
+
+4 pts
+ +
+
+ Battage à vrai (petit jan) + simple + ×1 + +4 +
+
+
+
+
+ +
+
+ + + diff --git a/doc/store_overview.md b/doc/store_overview.md deleted file mode 100644 index 573fb22..0000000 --- a/doc/store_overview.md +++ /dev/null @@ -1,290 +0,0 @@ -# Trictrac — store crate overview - -## 1. Module Map - -| Module | Responsibility | -| ---------------------- | ------------------------------------------------------------------------- | -| `board.rs` | Board representation, checker manipulation, quarter analysis | -| `dice.rs` | `Dice` struct, `DiceRoller`, bit encoding | -| `player.rs` | `Player` struct (score, bredouille), `Color`, `PlayerId`, `CurrentPlayer` | -| `game.rs` | `GameState` state machine, `GameEvent` enum, `Stage`/`TurnStage` | -| `game_rules_moves.rs` | `MoveRules`: move validation and generation | -| `game_rules_points.rs` | `PointsRules`: jan detection and scoring | -| `training_common.rs` | `TrictracAction` enum, action-space encoding (size 514) | -| `lib.rs` | Crate root, re-exports | - ---- - -## 2. Board Representation - -```rust -pub struct Board { - positions: [i8; 24], -} -``` - -- 24 fields indexed 0–23 internally, 1–24 externally. -- Positive values = White checkers on that field; negative = Black. -- Initial state: `[15, 0, ..., 0, -15]` — all 15 white pieces on field 1, all 15 black pieces on field 24. -- Field 0 is a sentinel for "exited the board" (never stored in the array). - -**Mirroring** is the central symmetry operation used throughout: - -```rust -pub fn mirror(&self) -> Self { - let mut positions = self.positions.map(|c| 0 - c); - positions.reverse(); - Board { positions } -} -``` - -This negates all values (swapping who owns each checker) and reverses the array (swapping directions). The entire engine always reasons from White's perspective; Black's moves are handled by mirroring the board first. - -**Quarter structure**: fields 1–6, 7–12, 13–18, 19–24. This maps to the four tables of Trictrac: - -- 1–6: White's "petit jan" (own table) -- 7–12: White's "grand jan" -- 13–18: Black's "grand jan" (= White's opponent territory) -- 19–24: Black's "petit jan" / White's "jan de retour" - -The "coin de repos" (rest corner) is field 12 for White, field 13 for Black. - ---- - -## 3. Dice - -```rust -pub struct Dice { - pub values: (u8, u8), -} -``` - -Dice are always a pair (never quadrupled for doubles, unlike Backgammon). The `DiceRoller` uses `StdRng` seeded from OS entropy (or an optional fixed seed for tests). Bit encoding: `"{d1:0>3b}{d2:0>3b}"` — 3 bits each, 6 bits total. - ---- - -## 4. Player State - -```rust -pub struct Player { - pub name: String, - pub color: Color, // White or Black - pub points: u8, // 0–11 (points within current hole) - pub holes: u8, // holes won (game ends at >12) - pub can_bredouille: bool, - pub can_big_bredouille: bool, - pub dice_roll_count: u8, // rolls since last new_pick_up() -} -``` - -`PlayerId` is a `u64` alias. Player 1 = White, Player 2 = Black (set at init time; this is fixed for the session in pyengine). - ---- - -## 5. Game State Machine - -### Stages - -```rust -pub enum Stage { PreGame, InGame, Ended } - -pub enum TurnStage { - RollDice, // 1 — player must request a roll - RollWaiting, // 0 — waiting for dice result from outside - MarkPoints, // 2 — points are being marked (schools mode only) - HoldOrGoChoice, // 3 — player won a hole; choose to Go or Hold - Move, // 4 — player must move checkers - MarkAdvPoints, // 5 — mark opponent's points after the move (schools mode) -} -``` - -### Turn lifecycle (schools disabled — the default) - -``` -RollWaiting - │ RollResult → auto-mark points - ├─[no hole]──→ Move - │ │ Move → mark opponent's points → switch player - │ └───────────────────────────────→ RollDice (next player) - └─[hole won]─→ HoldOrGoChoice - ├─ Go ──→ new_pick_up() → RollDice (same player) - └─ Move ──→ mark opponent's points → switch player → RollDice -``` - -In schools mode (`schools_enabled = true`), the player explicitly marks their own points (`Mark` event) and then the opponent's points after moving (`MarkAdvPoints` stage). - -### Key events - -```rust -pub enum GameEvent { - BeginGame { goes_first: PlayerId }, - EndGame { reason: EndGameReason }, - PlayerJoined { player_id, name }, - PlayerDisconnected { player_id }, - Roll { player_id }, // triggers RollWaiting - RollResult { player_id, dice }, // provides dice values - Mark { player_id, points }, // explicit point marking (schools mode) - Go { player_id }, // choose to restart position after hole - Move { player_id, moves: (CheckerMove, CheckerMove) }, - PlayError, -} -``` - -### Initialization in pyengine - -```rust -fn new() -> Self { - let mut game_state = GameState::new(false); // schools_enabled = false - game_state.init_player("player1"); - game_state.init_player("player2"); - game_state.consume(&GameEvent::BeginGame { goes_first: 1 }); - TricTrac { game_state } -} -``` - -Player 1 (White) always goes first. `active_player_id` uses 1-based indexing - ---- - -## 6. Scoring System (Jans) - -Points are awarded after each dice roll based on "jans" (scoring events) detected by `PointsRules`. All computation assumes White's perspective (board is mirrored for Black before calling). - -### Jan types - -| Jan | Points (normal / doublet) | Direction | -| ----------------------- | ------------------------- | --------------- | -| `TrueHitSmallJan` | 4 / 6 | → active player | -| `TrueHitBigJan` | 2 / 4 | → active player | -| `TrueHitOpponentCorner` | 4 / 6 | → active player | -| `FilledQuarter` | 4 / 6 | → active player | -| `FirstPlayerToExit` | 4 / 6 | → active player | -| `SixTables` | 4 / 6 | → active player | -| `TwoTables` | 4 / 6 | → active player | -| `Mezeas` | 4 / 6 | → active player | -| `FalseHitSmallJan` | −4 / −6 | → opponent | -| `FalseHitBigJan` | −2 / −4 | → opponent | -| `ContreTwoTables` | −4 / −6 | → opponent | -| `ContreMezeas` | −4 / −6 | → opponent | -| `HelplessMan` | −2 / −4 | → opponent | - -A single roll can trigger multiple jans, each scored independently. The jan detection process: - -1. Try both dice orderings -2. Detect "tout d'une" (combined dice move as a virtual single die) -3. Prefer true hits over false hits for the same move -4. Check quarter-filling opportunities -5. Check rare jans (SixTables at roll 3, TwoTables, Mezeas) given specific board positions and talon counts - -### Hole scoring - -```rust -fn mark_points(&mut self, player_id: PlayerId, points: u8) -> bool { - let sum_points = p.points + points; - let jeux = sum_points / 12; // number of completed holes - let holes = match (jeux, p.can_bredouille) { - (0, _) => 0, - (_, false) => 2 * jeux - 1, // no bredouille bonus - (_, true) => 2 * jeux, // bredouille doubles the holes - }; - p.points = sum_points % 12; - p.holes += holes; - ... -} -``` - -- 12 points = 1 "jeu", which yields 1 or 2 holes depending on bredouille status. -- Scoring any points clears the opponent's `can_bredouille`. -- Completing a hole resets `can_bredouille` for the scorer. -- Game ends when `holes >= 12`. - -### Points from both rolls - -After a roll, the active player's points (`dice_points.0`) are auto-marked immediately. After the Move, the opponent's points (`dice_points.1`) are marked (they were computed at roll-time from the pre-move board). - ---- - -## 7. Move Rules - -`MoveRules` always works from White's perspective. Key constraints enforced by `moves_allowed()`: - -1. **Opponent's corner forbidden**: Cannot land on field 13 (opponent's rest corner for White). -2. **Corner needs two checkers**: The rest corner (field 12) must be taken or vacated with exactly 2 checkers simultaneously. -3. **Corner by effect vs. by power**: If the corner can be taken directly ("par effet"), you cannot take it "par puissance" (using combined dice). -4. **Exit preconditions**: All checkers must be in fields 19–24 before any exit is allowed. -5. **Exit by effect priority**: If a normal exit is possible, exceedant moves (using overflow) are forbidden. -6. **Farthest checker first**: When exiting with exceedant, must exit the checker at the highest field. -7. **Must play all dice**: If both dice can be played, playing only one is invalid. -8. **Must play strongest die**: If only one die can be played, it must be the higher value die. -9. **Must fill quarter**: If a quarter can be completed, the move must complete it. -10. **Cannot block opponent's fillable quarter**: Cannot move into a quarter the opponent can still fill. - -The board state after each die application is simulated to check two-step sequences. - ---- - -## 8. Action Space (training_common.rs) - -Total size: **514 actions**. - -| Index | Action | Description | -| ------- | ------------------------------------------------ | -------------------------------------------- | -| 0 | `Roll` | Request dice roll | -| 1 | `Go` | After winning hole: reset board and continue | -| 2–257 | `Move { dice_order: true, checker1, checker2 }` | Move with die[0] first | -| 258–513 | `Move { dice_order: false, checker1, checker2 }` | Move with die[1] first | - -Move encoding: `index = 2 + (0 if dice_order else 256) + checker1 * 16 + checker2` - -`checker1` and `checker2` are **ordinal positions** (1-based) of specific checkers counted left-to-right across all White-occupied fields, not field indices. Checker 0 = "no move" (empty move). Range: 0–15 (16 values each). - -### Mirror pattern in get_legal_actions / apply_action - -For player 2 (Black): - -```rust -// get_legal_actions: mirror game state before computing -let mirror = self.game_state.mirror(); -get_valid_action_indices(&mirror) - -// apply_action: convert action → event on mirrored state, then mirror the event back -a.to_event(&self.game_state.mirror()) - .map(|e| e.get_mirror(false)) -``` - -This ensures Black's actions are computed as if Black were White on a mirrored board, then translated back to real-board coordinates. - ---- - -## 9. Known Issues and Inconsistencies - -### 9.1 Color swap on new_pick_up disabled - -In `game.rs:new_pick_up()`: - -```rust -// XXX : switch colors -// désactivé pour le moment car la vérification des mouvements échoue, -// cf. https://code.rhumbs.fr/henri/trictrac/issues/31 -// p.color = p.color.opponent_color(); -``` - -In authentic Trictrac, players swap colors between "relevés" (pick-ups after a hole is won with Go). This is commented out, so the same player always plays White and the same always plays Black throughout the entire game. - -### 9.2 `can_big_bredouille` tracked but not implemented - -The `can_big_bredouille` flag is stored in `Player` and serialized in state encoding, but the scoring logic never reads it. Grande bredouille (a rare extra bonus) is not implemented. - -### 9.3 `get_valid_actions` panics on `RollWaiting` - -```rust -TurnStage::MarkPoints | TurnStage::MarkAdvPoints | TurnStage::RollWaiting => { - panic!("get_valid_actions not implemented for turn stage {:?}", ...) -} -``` - -If `get_legal_actions` were ever called while `needs_roll()` is true, this would panic. - -### 9.4 Opponent points marked at pre-move board state - -The opponent's `dice_points.1` is computed at roll time (before the active player moves), but applied to the opponent after the move. This means the opponent's scoring is evaluated on the board position that existed before the active player moved — which is per the rules of Trictrac (points are based on where pieces could be hit at the moment of the roll), but it's worth noting this subtlety. diff --git a/doc/trictrac_rules.md b/doc/trictrac_rules.md deleted file mode 100644 index b88c399..0000000 --- a/doc/trictrac_rules.md +++ /dev/null @@ -1,211 +0,0 @@ -# Trictrac Rules — Quick Reference - -This document summarises the rules of grand trictrac based on the 2013 Malfilâtre edition ([full text](refs/laws_and_rules_of_trictrac.md)). - -French terms follow the mapping in [vocabulary.md](refs/vocabulary.md). - ---- - -## 1. Board and Starting Position - -- 24 triangular fields (_flèches_ / _cases_), numbered 1–24 from each player's perspective. -- 4 quarters of 6 fields: **small jan** (1–6), **big jan** (7–12), **opponent's big jan** (13–18), **return jan** (19–24, exit zone). -- Field 12 (White) / 13 (Black) is the **rest corner** (_coin de repos_). -- Each player starts with all 15 checkers in a stack (_talon_) on field 1. -- Checkers always move in the same direction (White: 1→24; Black: mirror of that). - -## 2. Dice and Movement - -- Both dice are rolled together; both must be played if possible. -- If only one can be played and there is a choice, the higher number must be played. -- A single checker may play both dice successively — a **chained move** (_tout d'une_) — stopping on an intermediate resting field (_repos_) between the two dice. -- **Fields are single-color**: a checker may only land on an empty field or one already occupied by own checkers. -- Landing on a field with ≥ 1 opponent checker is **forbidden** (blocked field). -- An unplayed number is a **helpless man** (_jan-qui-ne-peut_): 2 points penalty per unplayed die, credited to the opponent. - -## 3. The Rest Corner (Field 12 / 13) - -- Must be entered **simultaneously** (_d'emblée_): exactly 2 checkers must enter together. -- Must be vacated simultaneously: exactly 2 checkers must leave together. -- Always holds ≥ 2 checkers while occupied; a single checker there is forbidden. -- Two ways to take the corner: - - **By effect** (_par effet_): normal die values land exactly on it. - - **By puissance** (_par puissance_): the opponent's corner is empty; the player could land exactly on the opponent's corner, but by privilege he takes their own instead (as if stepping back one field). -- If both by-effect and by-puissance are possible, by-effect takes priority. -- An empty corner may serve as a resting field during a chained move (not a landing). -- Placing checkers on the **opponent's** corner is always forbidden. - -## 4. Scoring: Points and Holes - -- Points are tracked with tokens (0–11); **12 points = 1 hole** (_trou_). -- A **hole won bredouille** (_bredouille_) counts as **2 holes**: the active player scored 12 consecutive points from zero without the opponent scoring anything in between. The second player to start marking takes a double token (the _pavillon_ / flag) and can also win bredouille. -- The ordinary game ends when one player reaches **12 holes**. - -## 5. Scoring Events (Jans) - -All point values: normal roll / double. - -### 5a. Opening Jans (first rolls of a setting only) - -| Jan | Condition | Points | -| --------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------ | -| **Two tables jan** | First 2 checkers deployed; roll covers both rest corners; opponent's corner is empty | 4 / 6 (to player) | -| **Contre two tables** | Same, but opponent has already taken their corner | 4 / 6 (to opponent, as false hit) | -| **Mezeas jan** | Corner just taken (2 checkers); next roll shows one or two aces; opponent's corner empty | 4 per ace / 6 for double (to player) | -| **Contre mezeas** | Same, but opponent's corner is occupied | 4 / 6 (to opponent) | -| **Six tables jan** | After 2 rolls a checker is on 4 of the first 6 fields; 3rd roll could complete all 6 | 4 (always; not possible on a double) | - -### 5b. Jan Filling and Conserving - -A jan is **full** (_plein_) when all 6 of its fields hold ≥ 2 own checkers. - -- **Filling**: the last checker is brought in to complete the jan. - - Up to 3 ways: each direct die value covering the last field, or the combined sum (chained move). - - Each way: **4 / 6** points. - - Doubles allow at most 2 ways. - - "Filling in passing" (player must break the jan to play the other die) scores nothing. -- **Conserving**: both dice can be played without disturbing any of the 12 checkers of the full jan. - - Worth **4 / 6** points (at most one way). - - Conservation by helplessness (_par impuissance_): only die value 6 triggers this (smaller values can always be played within the jan by breaking it). - - The full return jan may be conserved by exiting one or more checkers. -- After marking points for filling, the player **must** actually fill the jan with the appropriate checker(s) — failure is a false move and a school. - -### 5c. Hitting (_Jan de Récompense_) - -Hitting is **always fictitious**: a checker is "hit" when a die value could cover an opponent checker on a half-field (_demi-case_), but **no actual checker moves**. The opponent's checker stays. - -**True hit**: a die (direct or combined sum) fictitiously covers the opponent checker. - -- In the **small jan table** (fields 1–12): **4 / 6** points per way. -- In the **big jan table** (fields 13–24): **2 / 4** points per way. - -Ways to hit: - -- **1 way**: only one direct die, or only the combined sum, covers the checker. -- **2 ways**: both direct dice cover it, or one die + the combined sum. -- **3 ways**: both dice + the combined sum (requires a normal roll; doubles max at 2 ways). - -**Combined-sum hit** requires a free **resting field** between the two dice stops: the field must be empty, own, or a single opponent checker (which is then also hit). - -**False hit** (_à faux_): the combined sum could hit but no valid resting field exists (all intermediate options are full opponent fields). The opponent gains the points the player would have scored. - -- True-hit points are always marked before false-hit points. -- A checker already hit truly cannot also be hit falsely in the same move. -- Multiple checkers may be hit simultaneously (some true, some false). - -**Corner hit**: player holds their own corner; opponent's corner is empty; the dice could simultaneously take the opponent's corner. Worth **4 / 6** points. Never false. - -### 5d. Exit - -- When all 15 checkers are in the return jan (fields 19–24), the player may exit. -- The exit rail counts as one additional field value. -- **Exact exit**: die value brings the checker directly to the exit rail — allowed. -- **Overflow** (_nombre excédant_): die value would carry the farthest checker past the rail — must exit. -- **Failing number** (_nombre défaillant_): die cannot reach or overflow — must play within the jan. -- A player may choose not to use an exact exit value and play within the jan instead — but overflow must always exit. -- It is forbidden to deliberately play a die within the jan to force the second die to be played as an overflow (using a checker closer to the exit). -- When the last checker exits: **4 points** on a normal roll, **6 points** on a double. -- After exit: checkers reset; the player who exited keeps first-move privilege for the new setting. - -## 6. Forbidden Jans - -A player **may not** place a checker in the opponent's small jan or big jan as long as the opponent can still materially complete a full jan there (i.e., enough of their own checkers remain to fill it). - -Exception: during a chained move, an empty field in the opponent's big jan (including their empty corner) may serve as a resting field to pass a checker into the return jan. - -## 7. Sequence of Play - -Each turn follows this order: - -1. Mark opponent's helpless man points or contre-jans from the **previous** move. -2. Mark opponent's schools; rectify false moves if any. -3. **Roll dice.** Opponent may mark schools for steps 1–2. -4. Mark own points: opening jans, hits, fills, conserves, exit. -5. Decide to **stay** (_tenir_) or **leave** (_s'en aller_) if a hole was won on own roll. -6. If exiting: reset checkers, keep token positions, roll again. -7. Play both dice. - -Points and holes must always be marked **before** touching checkers for the next move. - -## 8. Staying or Leaving - -After winning one or more holes on **own dice roll**, the player chooses: - -- **Stay** (_tenir_): mark holes, reset opponent token to zero, mark remainder points, continue. -- **Leave** (_s'en aller_): announce it; opponent agrees or raises a fault. All checkers and tokens reset to zero; only holes remain. Player who left has first-move privilege in the new setting. Remainder points are forfeited; opponent scores nothing for that move. - -If the winning points come from the **opponent's** roll (helpless man, schools), the player **must** stay — leaving is not an option. - ---- - -## 9. Schools (Marking Penalties) — _Not Yet Implemented_ - -Schools are penalties for marking errors. They are worth exactly the number of points over- or under-marked on the faulty move. They are marked last in the turn sequence. - -Key rules: - -- A school is committed once dice are rolled or a token has been advanced too far and released. -- The opponent is never obliged to mark a school — but if they do, it must be marked in full. -- **False school**: incorrectly claiming a school — itself becomes a school for the opponent. -- **School escalation** (_augmentation d'école_): dispute over a school that escalates back and forth. -- No "school of school" exists (marking a school is never itself penalised). -- No school of holes for marking a bredouille hole as simple; a school of points applies for forgetting holes due to earned points. - ---- - -## 10. The Scored Game (_Partie à Écrire_) — _Not Yet Implemented_ - -The scored game is played for an agreed number of **rounds** (_marqués_) and supports 2, 3, or 4 players (the 3/4-player format is called _chouette_). - -### Goal of a Round - -- A player must score at least **6 holes** and then **leave** to win the round. -- If both players are tied at ≥ 6 holes when one leaves, the round is **drawn** (_refait_) and replayed immediately. -- Winner of a round = player with the most holes after a leave. - -### Bredouille in the Scored Game - -- **Small bredouille** (_petite bredouille_): ≥ 6 consecutive holes → round counts **double**. -- **Big bredouille** (_grande bredouille_): ≥ 12 consecutive holes → round counts **quadruple**. -- The second player to score holes takes the flag (_pavillon_) at their peg. If the first player scores again, they take back the flag, cancelling both bredouilles. - -### Payments - -Each round is settled in tokens: - -- Winner receives (winner's holes − loser's holes) tokens, plus **consolation** of 2 tokens. -- Small bredouille: each winner hole worth 2 tokens; consolation = 4. -- Big bredouille: each winner hole worth 4 tokens; consolation = 8. -- Loser holes always deducted at 1 token each. -- In 3-player games, the non-playing player also receives consolation from the loser. -- Replays double the consolation price each time. - -A **queue** accumulates tokens from each defeat and is paid at game end to the player with the most tokens. - -**Bets** (_paris_): rounds played beyond each player's average (the _contingent_). The first double-bet is the **postillon** (28 tokens, including 20 from the queue); each subsequent bet costs 8 tokens. - -### Multi-Player Rotation (3 or 4 Players) - -- 3 players: after each round, the winner is replaced by the third player; first-move privilege stays with the player who remained. -- 4 players: two teams of two; each player plays two rounds in a row then gives way to their partner. -- Non-active players may advise (opponents in 3-player, teammates in 4-player) but may not touch game components. - ---- - -## 11. Implementation Status Summary - -| Feature | Status | -| ----------------------------------------------- | ------------------------------ | -| Board state, movement, rest corner | Implemented | -| Helpless man | Implemented | -| True / false hits (small jan, big jan, corner) | Implemented | -| Jan filling and conserving (small, big, return) | Implemented | -| Opening jans (two tables, mezeas, six tables) | Implemented | -| Exit and exit points | Implemented | -| Bredouille (hole bredouille) | Implemented (`can_bredouille`) | -| Forbidden jans | Implemented | -| Stay / leave (_s'en aller_) | Implemented (`Go` event) | -| Big bredouille (`can_big_bredouille`) | Field exists, not used | -| Schools | Not implemented | -| Scored game / rounds | Not implemented | -| Misery pile (_pile de misère_) | Not implemented | diff --git a/doc/ui-mockup/inGame-moving.html b/doc/ui-mockup/inGame-moving.html deleted file mode 100644 index 5b47414..0000000 --- a/doc/ui-mockup/inGame-moving.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Trictrac - - - - - - -
Trictrac
Debug
Anonyme (vous)
6/12
B
Bot
0/12
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
8
12
11
10
9
8
7
6
5
4
3
2
1
8
grand jan
petit jan
Déplacez une dame (2 sur 2)

Cliquez un champ surligné pour déplacer

diff --git a/doc/ui-mockup/inGame-pointsForOpponent.html b/doc/ui-mockup/inGame-pointsForOpponent.html deleted file mode 100644 index da85a24..0000000 --- a/doc/ui-mockup/inGame-pointsForOpponent.html +++ /dev/null @@ -1,11 +0,0 @@ - - - - Trictrac - - - - - - -
Trictrac
Debug
Anonyme (vous)
0/12
B
Bot
0/12
B
Adversaire +8 pts
Battage à vrai (grand jan)simple×4+8
Trou adverse ! 1/12
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
10
12
11
10
9
8
7
6
5
4
3
2
1
10
grand jan
petit jan
L'adversaire a lancé les dés

Cliquez Continuer quand vous êtes prêt

diff --git a/doc/ui-mockup/inGame-pointsForUser.html b/doc/ui-mockup/inGame-pointsForUser.html deleted file mode 100644 index 4b21963..0000000 --- a/doc/ui-mockup/inGame-pointsForUser.html +++ /dev/null @@ -1,9 +0,0 @@ - - - - Trictrac - - - - -
Trictrac
Debug
Anonyme (vous)
6/12
B
Bot
0/12
+6 pts
Battage à vrai (petit jan)simple×1+4
Battage à vrai (grand jan)simple×1+2
jan de retour
13
14
15
16
17
18
19
20
21
22
23
24
9
12
11
10
9
8
7
6
5
4
3
2
1
10
grand jan
petit jan
Déplacez une dame (1 sur 2)

Cliquez un champ surligné pour déplacer

diff --git a/flake.nix b/flake.nix index 3da667e..a256857 100644 --- a/flake.nix +++ b/flake.nix @@ -103,7 +103,7 @@ trictrac = with final; rustPlatform.buildRustPackage { pname = "trictrac"; - version = "0.2.15"; # trictrac-version + version = "0.2.16"; # trictrac-version src = ./.; nativeBuildInputs = [ pkg-config ]; diff --git a/store/src/game_rules_moves.rs b/store/src/game_rules_moves.rs index 82ae788..8a2f741 100644 --- a/store/src/game_rules_moves.rs +++ b/store/src/game_rules_moves.rs @@ -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, diff --git a/store/src/lib.rs b/store/src/lib.rs index 5d759b6..7f62f53 100644 --- a/store/src/lib.rs +++ b/store/src/lib.rs @@ -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};