trictrac/doc/client_web_overview.md

290 lines
11 KiB
Markdown
Raw Normal View History

2026-04-08 20:40:47 +02:00
# 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<Screen>`.
---
## State Management
### Root signals (live in `App`, provided via Leptos context)
| Signal | Type | Purpose |
|--------|------|---------|
| `screen` | `RwSignal<Screen>` | Which screen is shown |
| `pending` | `RwSignal<VecDeque<GameUiState>>` | Buffered states awaiting "Continue" |
| `cmd_tx` | `UnboundedSender<NetCommand>` | 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<Option<u8>>` | First clicked field during move staging |
| `staged_moves` | `RwSignal<Vec<(u8, u8)>>` | Accumulated (origin, dest) pairs for this turn |
| `hovered_jan_moves` | `RwSignal<Vec<(CheckerMove, CheckerMove)>>` | 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<ViewState>, // 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<JanEntry>` — 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 [1318] | TOP-RIGHT [1924]
─────────────────────────────────────────
BOT-LEFT [127] | BOT-RIGHT [61]
Black's view (mirror):
TOP-LEFT [16] | TOP-RIGHT [712]
─────────────────────────────────────────
BOT-LEFT [2419] | BOT-RIGHT [1813]
```
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<JanEntry>` — 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 (012) and holes (012). 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 (011), holes (012), 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/`.