Compare commits

...

3 commits

5 changed files with 197 additions and 96 deletions

View file

@ -249,12 +249,15 @@ body {
} }
/* ── Game container ─────────────────────────────────────────────────── */ /* ── Game container ─────────────────────────────────────────────────── */
/* No width: 100% let it size to content (the board wrapper, ~832px).
This keeps the board pinned at the same horizontal position whether or
not the side panel is visible, and aligns the status bar / score panels
with the board rather than with the viewport edge. */
.game-container { .game-container {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.6rem; gap: 0.6rem;
width: 100%;
} }
/* ── Language switcher (in-game) ────────────────────────────────────── */ /* ── Language switcher (in-game) ────────────────────────────────────── */
@ -325,17 +328,25 @@ body {
} }
/* ── Player score panel ─────────────────────────────────────────────── */ /* ── Player score panel ─────────────────────────────────────────────── */
/* Horizontal banner: name on the left, score bars expanding to fill the
board width no more empty right half on large screens. */
.player-score-panel { .player-score-panel {
background: var(--ui-parchment); background: var(--ui-parchment);
border-radius: 5px; border-radius: 5px;
padding: 0.45rem 1rem; padding: 0.45rem 1.25rem;
font-size: 0.88rem; font-size: 0.88rem;
box-shadow: 0 2px 6px rgba(0,0,0,0.25); box-shadow: 0 2px 6px rgba(0,0,0,0.25);
width: 100%; width: 100%;
border-top: 2px solid var(--ui-gold-dark); border-top: 2px solid var(--ui-gold-dark);
display: flex;
align-items: center;
gap: 1.5rem;
} }
.player-score-header { margin-bottom: 0.35rem; } .player-score-header {
flex-shrink: 0;
min-width: 90px;
}
.player-name { .player-name {
font-family: var(--font-display); font-family: var(--font-display);
@ -345,25 +356,28 @@ body {
letter-spacing: 0.02em; letter-spacing: 0.02em;
} }
.score-bars { display: flex; flex-direction: column; gap: 5px; } /* Bars sit side-by-side (points | holes) filling remaining width */
.score-bars { display: flex; flex-direction: row; gap: 1.5rem; flex: 1; align-items: center; }
.score-bar-row { .score-bar-row {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
flex: 1;
} }
.score-bar-label { .score-bar-label {
font-size: 0.75rem; font-size: 0.75rem;
color: #665544; color: #665544;
width: 3.5rem; width: 3rem;
text-align: right; text-align: right;
flex-shrink: 0; flex-shrink: 0;
} }
/* ── Points bar ─────────────────────────────────────────────────────── */ /* ── Points bar ─────────────────────────────────────────────────────── */
.score-bar { .score-bar {
width: 140px; flex: 1;
max-width: 220px;
height: 8px; height: 8px;
background: rgba(0,0,0,0.1); background: rgba(0,0,0,0.1);
border-radius: 4px; border-radius: 4px;
@ -424,18 +438,21 @@ body {
} }
/* ── Board + side panel ─────────────────────────────────────────────── */ /* ── Board + side panel ─────────────────────────────────────────────── */
/* .board-and-panel is sized to the board wrapper only; the side panel is
positioned absolutely so it floats to the right without pushing the
board and breaking its horizontal alignment. */
.board-and-panel { .board-and-panel {
display: flex; position: relative;
flex-direction: row;
align-items: flex-start;
gap: 1rem;
} }
.side-panel { .side-panel {
position: absolute;
left: calc(100% + 1rem);
top: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 0.65rem; gap: 0.65rem;
min-width: 160px; width: 200px;
padding-top: 0.15rem; padding-top: 0.15rem;
} }
@ -785,13 +802,15 @@ body {
.board-quarter .field.zone-petit:nth-child(even), .board-quarter .field.zone-petit:nth-child(even),
.board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); } .board-quarter .field.zone-grand:nth-child(even) { --fc: var(--field-ivory); }
/* Jan de retour — cooler: dark teal / silvery-green ivory */ /* Opponent's grand-jan deep slate-blue / silvery-green ivory.
.board-quarter .field.zone-retour:nth-child(odd) { --fc: #1e3d32; } Previously #1e3d32 was nearly identical to the felt (#1d3d28); now using
.board-quarter .field.zone-retour:nth-child(even) { --fc: #e5eadc; } a clearly distinguishable cool blue that reads well against the green. */
.board-quarter .field.zone-opponent:nth-child(odd) { --fc: #1a4f72; }
.board-quarter .field.zone-opponent:nth-child(even) { --fc: #e5eadc; }
/* Dernier jan — warmer: amber-brown / warm amber ivory */ /* Jan de retour — warmer: amber-brown / warm amber ivory */
.board-quarter .field.zone-dernier:nth-child(odd) { --fc: #6a2810; } .board-quarter .field.zone-retour:nth-child(odd) { --fc: #6a2810; }
.board-quarter .field.zone-dernier:nth-child(even) { --fc: #f2dfa0; } .board-quarter .field.zone-retour:nth-child(even) { --fc: #f2dfa0; }
/* ── Rest corner (§3) — before .clickable so green wins when interactive ── */ /* ── Rest corner (§3) — before .clickable so green wins when interactive ── */
.field.corner { --fc: var(--field-corner) !important; } .field.corner { --fc: var(--field-corner) !important; }
@ -827,6 +846,29 @@ body {
animation: exit-glow 2s ease-in-out infinite; animation: exit-glow 2s ease-in-out infinite;
} }
/* ── §6c — Jan hover field highlight ────────────────────────────────── */
.field.jan-hovered {
--fc: rgba(190, 140, 35, 0.8) !important;
}
/* ── §6e — Hit (battue) ripple ──────────────────────────────────────── */
@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; }
/* ── Interactive states — after .corner to take visual priority ─────── */ /* ── Interactive states — after .corner to take visual priority ─────── */
.field.clickable { .field.clickable {
cursor: pointer; cursor: pointer;
@ -852,7 +894,7 @@ body {
.board-quarter .field.zone-petit:nth-child(odd) .field-num, .board-quarter .field.zone-petit:nth-child(odd) .field-num,
.board-quarter .field.zone-grand: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-retour:nth-child(odd) .field-num,
.board-quarter .field.zone-dernier:nth-child(odd) .field-num { .board-quarter .field.zone-opponent:nth-child(odd) .field-num {
color: rgba(240,215,190,0.38); color: rgba(240,215,190,0.38);
} }

View file

@ -32,8 +32,8 @@ fn field_zone_class(field_num: u8) -> &'static str {
match field_num { match field_num {
1..=6 => "zone-petit", 1..=6 => "zone-petit",
7..=12 => "zone-grand", 7..=12 => "zone-grand",
13..=18 => "zone-retour", 13..=18 => "zone-opponent",
19..=24 => "zone-dernier", 19..=24 => "zone-retour",
_ => "", _ => "",
} }
} }
@ -143,7 +143,6 @@ fn field_center(f: usize, is_white: bool) -> Option<(f32, f32)> {
Some((x, y)) Some((x, y))
} }
/// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`. /// SVG `<g>` element drawing one arrow (shadow + gold) from `fp` to `tp`.
fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView { fn arrow_svg(fp: (f32, f32), tp: (f32, f32)) -> AnyView {
let (x1, y1) = fp; let (x1, y1) = fp;
@ -263,6 +262,9 @@ pub fn Board(
/// Checker moves to animate on mount (None when board unchanged). /// Checker moves to animate on mount (None when board unchanged).
#[prop(default = None)] #[prop(default = None)]
last_moves: Option<(CheckerMove, CheckerMove)>, last_moves: Option<(CheckerMove, CheckerMove)>,
/// Fields where a hit (battue) was scored this turn — show ripple animation.
#[prop(default = vec![])]
hit_fields: Vec<u8>,
) -> impl IntoView { ) -> impl IntoView {
let board = view_state.board; let board = view_state.board;
let is_move_stage = view_state.active_mp_player == Some(player_id) let is_move_stage = view_state.active_mp_player == Some(player_id)
@ -318,6 +320,8 @@ pub fn Board(
(dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy)) (dx.abs() >= 1.0 || dy.abs() >= 1.0).then_some((dx, dy))
}) })
}); });
// §6e — ripple on hit fields (battue).
let is_hit_field = hit_fields.contains(&field_num);
view! { view! {
<div <div
id={format!("field-{field_num}")} id={format!("field-{field_num}")}
@ -372,6 +376,21 @@ pub fn Board(
} }
} }
// §6c: highlight fields touched by the hovered jan
if let Some(hm) = hovered_moves {
let pairs = hm.get();
let f = field_num as usize;
let highlighted = pairs.iter().any(|(m1, m2)| {
(m1.get_from() != 0 && m1.get_from() == f)
|| (m1.get_to() != 0 && m1.get_to() == f)
|| (m2.get_from() != 0 && m2.get_from() == f)
|| (m2.get_to() != 0 && m2.get_to() == f)
});
if highlighted {
cls.push_str(" jan-hovered");
}
}
cls cls
} }
on:click=move |_| { on:click=move |_| {
@ -423,7 +442,14 @@ pub fn Board(
let moves = staged_moves.get(); let moves = staged_moves.get();
let val = displayed_value(board, &moves, is_white, field_num); let val = displayed_value(board, &moves, is_white, field_num);
let count = val.unsigned_abs(); let count = val.unsigned_abs();
(count > 0).then(|| { // §6e — ripple on hit (battue) fields; must be inside the
// reactive closure so Leptos uses the same direct rendering
// path as .arriving (avoids node-move that resets animation).
let ripple = is_hit_field.then(|| {
let cls = if is_top_row { "hit-ripple hit-ripple-top" } else { "hit-ripple hit-ripple-bot" };
view! { <div class=cls></div> }.into_any()
});
let stack = (count > 0).then(|| {
let color = if val > 0 { "white" } else { "black" }; let color = if val > 0 { "white" } else { "black" };
let display_n = (count as usize).min(4); let display_n = (count as usize).min(4);
// outermost index: last for top rows, first for bottom rows. // outermost index: last for top rows, first for bottom rows.
@ -448,8 +474,9 @@ pub fn Board(
<div class=format!("checker {color}")>{label}</div> <div class=format!("checker {color}")>{label}</div>
}.into_any() }.into_any()
}).collect(); }).collect();
view! { <div class="checker-stack">{chips}</div> } view! { <div class="checker-stack">{chips}</div> }.into_any()
}) });
(ripple, stack)
}} }}
</div> </div>
} }
@ -487,7 +514,6 @@ pub fn Board(
} }
}; };
let (tl, tr, bl, br) = if is_white { let (tl, tr, bl, br) = if is_white {
(&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W) (&TOP_LEFT_W, &TOP_RIGHT_W, &BOT_LEFT_W, &BOT_RIGHT_W)
} else { } else {
@ -496,9 +522,9 @@ pub fn Board(
// Zone label pairs (top-left, top-right, bot-left, bot-right) per perspective. // 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 { let (label_tl, label_tr, label_bl, label_br) = if is_white {
("jan de retour", "dernier jan", "grand jan", "petit jan") ("", "jan de retour", "grand jan", "petit jan")
} else { } else {
("petit jan", "grand jan", "dernier jan", "jan de retour") ("petit jan", "grand jan", "jan de retour", "")
}; };
view! { view! {

View file

@ -2,7 +2,7 @@ use std::collections::VecDeque;
use futures::channel::mpsc::UnboundedSender; use futures::channel::mpsc::UnboundedSender;
use leptos::prelude::*; use leptos::prelude::*;
use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, MoveRules}; use trictrac_store::{Board as StoreBoard, CheckerMove, Color, Dice as StoreDice, Jan, MoveRules};
use crate::app::{GameUiState, NetCommand, PauseReason}; use crate::app::{GameUiState, NetCommand, PauseReason};
use crate::i18n::*; use crate::i18n::*;
@ -121,6 +121,39 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
let last_moves = state.last_moves; let last_moves = state.last_moves;
// §6e — fields where a battue (hit) was scored; ripple animation shown there.
let hit_fields: Vec<u8> = {
let is_hit_jan = |jan: &Jan| matches!(
jan,
Jan::TrueHitSmallJan
| Jan::TrueHitBigJan
| Jan::TrueHitOpponentCorner
| Jan::FalseHitSmallJan
| Jan::FalseHitBigJan
);
let mut fields: Vec<u8> = vec![];
for event_opt in [&my_scored_event, &opp_scored_event] {
if let Some(event) = event_opt {
for entry in &event.jans {
if is_hit_jan(&entry.jan) {
for (m1, m2) in &entry.moves {
for m in [m1, m2] {
let to = m.get_to() as u8;
if to != 0 && !fields.contains(&to) {
fields.push(to);
}
}
}
}
}
}
}
if !fields.is_empty() {
leptos::logging::log!("[6e] hit_fields = {:?}", fields);
}
fields
};
// ── Capture for closures ─────────────────────────────────────────────────── // ── Capture for closures ───────────────────────────────────────────────────
let stage = vs.stage.clone(); let stage = vs.stage.clone();
let turn_stage = vs.turn_stage.clone(); let turn_stage = vs.turn_stage.clone();
@ -217,6 +250,7 @@ pub fn GameScreen(state: GameUiState) -> impl IntoView {
bar_is_move=is_move_stage bar_is_move=is_move_stage
bar_is_double=is_double_dice bar_is_double=is_double_dice
last_moves=last_moves last_moves=last_moves
hit_fields=hit_fields
/> />
// ── Side panel (scoring panels only) ───────────────────────── // ── Side panel (scoring panels only) ─────────────────────────

View file

@ -37,7 +37,7 @@ The board body between triangles becomes visible as the wood/felt surface — th
**Proposals**: **Proposals**:
### 2a. Zone labels ### 2a. Zone labels
Add thin labels (`"petit jan"`, `"grand jan"`, `"jan de retour"`, `"dernier jan"`) 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. 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 ### 2b. Field color shift per zone
The physical game uses alternating colors within each quarter, but different quarters can use slightly different base hues: The physical game uses alternating colors within each quarter, but different quarters can use slightly different base hues:

View file

@ -3,7 +3,7 @@
This table maps the French game terminology to the English terms used in this codebase (primarily the `store` crate). Where a code identifier exists, it is shown in `monospace`. This table maps the French game terminology to the English terms used in this codebase (primarily the `store` crate). Where a code identifier exists, it is shown in `monospace`.
| French | English (code) | Notes | | French | English (code) | Notes |
|---|---|---| | -------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------- |
| tablier | board | `Board` | | tablier | board | `Board` |
| case / flèche | field | `Field` (124, 0 = exit); "flèche" (arrow) and "case" both refer to a field/point | | case / flèche | field | `Field` (124, 0 = exit); "flèche" (arrow) and "case" both refer to a field/point |
| demi-case | half-field | A field occupied by exactly one checker | | demi-case | half-field | A field occupied by exactly one checker |
@ -14,8 +14,7 @@ This table maps the French game terminology to the English terms used in this co
| bande de sortie | exit rail | Same rail, used as an extra field value during exit | | bande de sortie | exit rail | Same rail, used as an extra field value during exit |
| petit jan | small jan | Fields 16; `is_field_in_small_jan` | | petit jan | small jan | Fields 16; `is_field_in_small_jan` |
| grand jan | big jan | Fields 712 (White's side, opponent's near zone) | | grand jan | big jan | Fields 712 (White's side, opponent's near zone) |
| jan de retour | return jan | Fields 1318; same fields as opponent's small jan | | jan de retour | return jan | Fields 1924; same fields as opponent's small jan ; where checkers gather before exiting; `last quarter` |
| dernier jan / jan de retour | last jan / last quarter | Fields 1924; where checkers gather before exiting; `last quarter` |
| table des petits jans | small jan table | The board half containing both players' small jans (fields 112) | | table des petits jans | small jan table | The board half containing both players' small jans (fields 112) |
| table des grands jans | big jan table | The board half containing both players' big jans (fields 1324) | | table des grands jans | big jan table | The board half containing both players' big jans (fields 1324) |
| plein (d'un jan) | filled (jan) | All 6 fields of a jan hold ≥ 2 checkers | | plein (d'un jan) | filled (jan) | All 6 fields of a jan hold ≥ 2 checkers |