Compare commits
10 Commits
d631af71dc
...
07adf0208d
| Author | SHA1 | Date | |
|---|---|---|---|
| 07adf0208d | |||
|
|
94c6949426 | ||
|
|
0c5449cd1e | ||
|
|
56ab1fb2e3 | ||
|
|
95d04f8d9e | ||
|
|
e209ad3e1f | ||
|
|
162d6a7e59 | ||
|
|
f0437bbbbe | ||
|
|
7af2fc9f35 | ||
|
|
72ffa15489 |
71
README.md
71
README.md
@ -1,6 +1,6 @@
|
|||||||
## curltastic
|
## curltastic
|
||||||
|
|
||||||
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick Red or Yellow freely, and throw when it is that team's turn (solo pass-and-play or remote opponents).
|
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick **Team 1** or **Team 2** freely, and throw when it is that team's turn (solo pass-and-play or remote opponents). Default palette: Team 1 red, Team 2 yellow.
|
||||||
|
|
||||||
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
||||||
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
||||||
@ -25,45 +25,60 @@ A multiplayer 2D curling game for mobile browser. Any number of clients can join
|
|||||||
```
|
```
|
||||||
http://localhost:5173/?room=DEMO1
|
http://localhost:5173/?room=DEMO1
|
||||||
```
|
```
|
||||||
Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device.
|
Use the team dropdown to switch between Team 1 and Team 2 anytime. Use *Copy share link* to invite another device.
|
||||||
|
|
||||||
### Mobile devices
|
|
||||||
|
|
||||||
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. The default view is zoomed in on the house; drag the sheet vertically to scroll up to the hog line.
|
|
||||||
|
|
||||||
### Controls
|
### Controls
|
||||||
|
|
||||||
- When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house.
|
- On your team's turn, drag on solid ice to place the broom (aim is **not** limited to the house).
|
||||||
- When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line).
|
- Otherwise, drag to pan (default framing shows house + sidelines; pan up toward the hog line).
|
||||||
- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
|
- Team dropdown: free switch mid-game (solo: throw for Team 1, switch, throw for Team 2).
|
||||||
- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar.
|
- Velocity slider (release speed, m/s), curl buttons, friction scalar **0.5–1.5** (local; multiplies ice µ(v) table).
|
||||||
- Tap **THROW**. Anyone identifying as the turn team may throw.
|
- Tap **THROW**. Anyone joined as the current turn team may throw.
|
||||||
- The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together.
|
- Parallel multi-stone trajectories; rotation θ comes from physics.
|
||||||
|
- Skeuomorphic 2×8 stones-left HUD; end-of-end modal with scoreboard (auto ~5s + manual dismiss).
|
||||||
|
|
||||||
### Architecture
|
### Physics (high level)
|
||||||
|
|
||||||
- WebSocket JSON protocol with tagged messages.
|
- Pure initial **velocity** (m/s), not discrete weight.
|
||||||
- Server simulates each throw at 120 Hz and sends a subset of `(x, y, t)` path points at 40 Hz.
|
- Ice friction µ(v) table (interpolated) × local scalar; linear and angular damping share the table.
|
||||||
- All game state, scoring, end management, and hammer rules live on the server.
|
- Curl: initial |ω| = 5 rot / 14 s; lateral continuous model (clockwise → right).
|
||||||
- Disconnects are tolerated: the room and turn remain in memory.
|
- Stone–stone contacts are **nearly elastic** (`STONE_RESTITUTION = 0.9`) so takeouts launch both rocks along the impact line instead of plastic-sticking.
|
||||||
|
- Back line and sidelines are **not** colliders — touch → out of play after sim.
|
||||||
|
- Stone ids: `{ team, n }` with n = 1…8 per team per end.
|
||||||
|
- Foot-derived radii use `FEET_TO_METERS = 0.3048`.
|
||||||
|
|
||||||
|
### Architecture / protocol
|
||||||
|
|
||||||
|
- WebSocket JSON, snake_case tags.
|
||||||
|
- `game_state` includes totals, hammer, turn_team, **scoreboard** (per end: hammer, team1 pts, team2 pts), **stones_remaining**, stones.
|
||||||
|
- `trajectories` message: `{ stones: [{ stone_id, rotation, team, trajectory: [[x,y,theta], ...] }] }` sampled at 40 Hz from 120 Hz sim (`t = index / 40`).
|
||||||
|
- No room-full limit; no `end_scored` message (scoreboard replaces it).
|
||||||
|
|
||||||
### E2E tests
|
### E2E tests
|
||||||
|
|
||||||
With the backend and frontend dev server running:
|
With the backend running (`cargo run --release` on :3000):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd e2e
|
cd e2e
|
||||||
npm install -g ws # or npm install ws locally in the project
|
npm install -g ws # or npm install ws
|
||||||
node e2e_test.cjs # room lifecycle, throw, trajectory
|
node e2e_test.cjs
|
||||||
node e2e_score.cjs # alternate turns / stones in play
|
node e2e_score.cjs
|
||||||
node e2e_persistence.cjs # multi-throw stone persistence
|
node e2e_persistence.cjs
|
||||||
node e2e_multi_client.cjs # 3 clients share state (no room-full)
|
node e2e_multi_client.cjs
|
||||||
node e2e_end_score.cjs # full end → end_scored + next end
|
node e2e_end_score.cjs # full end → scoreboard entry + next end
|
||||||
node collision_trajectory_qa.cjs # multi-stone trajectory on collision
|
node collision_trajectory_qa.cjs
|
||||||
|
node load_test.cjs # optional concurrent rooms
|
||||||
```
|
```
|
||||||
|
|
||||||
### Limitations / known simplifications
|
Unit tests: `cd backend && cargo test`, `cd frontend && npm test`.
|
||||||
|
|
||||||
|
### Branches (this work)
|
||||||
|
|
||||||
|
- **`jasonlooked`** — PR-A refactor: multi-client, multi-path animation, free broom, camera, FEET_TO_METERS.
|
||||||
|
- **`pr2-features`** — PR-B features: physics rewrite, team1/2 wire, scoreboard, HUD, modal (branched from PR-A tip).
|
||||||
|
|
||||||
|
### Limitations
|
||||||
|
|
||||||
- No accounts, persistence, anti-cheat, replay log, or turn timer.
|
- No accounts, persistence, anti-cheat, replay log, or turn timer.
|
||||||
- Curl is fixed as a function of release speed (more curl at lower speed); sweeping is not implemented.
|
- Sweeping not implemented; friction/curl not shared-room state.
|
||||||
- Stones that pass the back line or leave the sheet are removed from play.
|
- Stones past back/sideline/hog rules are removed from play after simulation.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
use crate::protocol::*;
|
|
||||||
use crate::physics::PhysicsWorld;
|
use crate::physics::PhysicsWorld;
|
||||||
|
use crate::protocol::*;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
pub enum GamePhase {
|
pub enum GamePhase {
|
||||||
@ -19,14 +19,13 @@ pub struct Game {
|
|||||||
turn_team: Team,
|
turn_team: Team,
|
||||||
physics: PhysicsWorld,
|
physics: PhysicsWorld,
|
||||||
active_stones: Vec<StoneState>,
|
active_stones: Vec<StoneState>,
|
||||||
stones_red: u8,
|
stones_team1: u8,
|
||||||
stones_yellow: u8,
|
stones_team2: u8,
|
||||||
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
scoreboard: Vec<EndScore>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct ThrowOutcome {
|
pub struct ThrowOutcome {
|
||||||
pub trajectory: Vec<StoneTrajectory>,
|
pub trajectories: Vec<StonePath>,
|
||||||
pub end_scored: Option<ServerMessage>,
|
|
||||||
pub state_message: ServerMessage,
|
pub state_message: ServerMessage,
|
||||||
pub game_over: Option<ServerMessage>,
|
pub game_over: Option<ServerMessage>,
|
||||||
}
|
}
|
||||||
@ -37,24 +36,29 @@ impl Game {
|
|||||||
phase: GamePhase::Waiting,
|
phase: GamePhase::Waiting,
|
||||||
end: 1,
|
end: 1,
|
||||||
scores: [0, 0],
|
scores: [0, 0],
|
||||||
hammer: Team::Red,
|
hammer: Team::Team1,
|
||||||
turn_team: Team::Red,
|
turn_team: Team::Team1,
|
||||||
physics: PhysicsWorld::new(),
|
physics: PhysicsWorld::new(),
|
||||||
active_stones: Vec::new(),
|
active_stones: Vec::new(),
|
||||||
stones_red: STONES_PER_TEAM,
|
stones_team1: STONES_PER_TEAM,
|
||||||
stones_yellow: STONES_PER_TEAM,
|
stones_team2: STONES_PER_TEAM,
|
||||||
last_end_scored: None,
|
scoreboard: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn start(&mut self) {
|
pub fn start(&mut self) {
|
||||||
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
self.hammer = if rand::random() {
|
||||||
|
Team::Team1
|
||||||
|
} else {
|
||||||
|
Team::Team2
|
||||||
|
};
|
||||||
self.turn_team = self.hammer.other();
|
self.turn_team = self.hammer.other();
|
||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
self.end = 1;
|
self.end = 1;
|
||||||
self.stones_red = STONES_PER_TEAM;
|
self.stones_team1 = STONES_PER_TEAM;
|
||||||
self.stones_yellow = STONES_PER_TEAM;
|
self.stones_team2 = STONES_PER_TEAM;
|
||||||
self.scores = [0, 0];
|
self.scores = [0, 0];
|
||||||
|
self.scoreboard.clear();
|
||||||
self.physics.reset();
|
self.physics.reset();
|
||||||
self.physics.reset_stone_ids();
|
self.physics.reset_stone_ids();
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
@ -65,10 +69,10 @@ impl Game {
|
|||||||
team: Team,
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
velocity: f32,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<Vec<StoneTrajectory>, String> {
|
) -> Result<Vec<StonePath>, String> {
|
||||||
if self.turn_team != team {
|
if self.turn_team != team {
|
||||||
return Err("Not your turn".to_string());
|
return Err("Not your turn".to_string());
|
||||||
}
|
}
|
||||||
@ -77,13 +81,15 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
let trajectory =
|
||||||
|
self.physics
|
||||||
|
.throw(self.turn_team, broom_x, broom_y, velocity, curl, friction)?;
|
||||||
self.active_stones = self.physics.current_stones();
|
self.active_stones = self.physics.current_stones();
|
||||||
self.phase = GamePhase::Simulating;
|
self.phase = GamePhase::Simulating;
|
||||||
|
|
||||||
match self.turn_team {
|
match self.turn_team {
|
||||||
Team::Red => self.stones_red = self.stones_red.saturating_sub(1),
|
Team::Team1 => self.stones_team1 = self.stones_team1.saturating_sub(1),
|
||||||
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
Team::Team2 => self.stones_team2 = self.stones_team2.saturating_sub(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(trajectory)
|
Ok(trajectory)
|
||||||
@ -94,14 +100,13 @@ impl Game {
|
|||||||
team: Team,
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
velocity: f32,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<ThrowOutcome, String> {
|
) -> Result<ThrowOutcome, String> {
|
||||||
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
|
let trajectories = self.handle_throw(team, broom_x, broom_y, velocity, curl, friction)?;
|
||||||
self.finish_simulation();
|
self.finish_simulation();
|
||||||
|
|
||||||
let end_scored = self.take_last_end_scored();
|
|
||||||
let state_message = self.game_state_message();
|
let state_message = self.game_state_message();
|
||||||
let game_over = if self.phase == GamePhase::GameComplete {
|
let game_over = if self.phase == GamePhase::GameComplete {
|
||||||
Some(self.game_over_message())
|
Some(self.game_over_message())
|
||||||
@ -110,8 +115,7 @@ impl Game {
|
|||||||
};
|
};
|
||||||
|
|
||||||
Ok(ThrowOutcome {
|
Ok(ThrowOutcome {
|
||||||
trajectory,
|
trajectories,
|
||||||
end_scored,
|
|
||||||
state_message,
|
state_message,
|
||||||
game_over,
|
game_over,
|
||||||
})
|
})
|
||||||
@ -126,7 +130,7 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn score_end_internal(&mut self, force: bool) {
|
fn score_end_internal(&mut self, force: bool) {
|
||||||
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
|
let end_done = force || (self.stones_team1 == 0 && self.stones_team2 == 0);
|
||||||
if !end_done {
|
if !end_done {
|
||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
self.turn_team = self.turn_team.other();
|
self.turn_team = self.turn_team.other();
|
||||||
@ -134,8 +138,10 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.phase = GamePhase::Scoring;
|
self.phase = GamePhase::Scoring;
|
||||||
|
let end_hammer = self.hammer;
|
||||||
let states = self.physics.stone_states_for_scoring();
|
let states = self.physics.stone_states_for_scoring();
|
||||||
let mut by_distance: Vec<_> = states.iter()
|
let mut by_distance: Vec<_> = states
|
||||||
|
.iter()
|
||||||
.map(|(id, team, x, y)| {
|
.map(|(id, team, x, y)| {
|
||||||
let dx = x - HOUSE_CENTER.0;
|
let dx = x - HOUSE_CENTER.0;
|
||||||
let dy = y - HOUSE_CENTER.1;
|
let dy = y - HOUSE_CENTER.1;
|
||||||
@ -147,7 +153,7 @@ impl Game {
|
|||||||
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
|
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
|
||||||
let mut points = 0;
|
let mut points = 0i32;
|
||||||
if let Some(team) = scoring_team {
|
if let Some(team) = scoring_team {
|
||||||
for (_, _, t, _, _) in &by_distance {
|
for (_, _, t, _, _) in &by_distance {
|
||||||
if *t == team {
|
if *t == team {
|
||||||
@ -158,18 +164,25 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if points > 0 {
|
if points > 0 {
|
||||||
let team_idx = match team {
|
self.scores[team.index()] += points;
|
||||||
Team::Red => 0,
|
|
||||||
Team::Yellow => 1,
|
|
||||||
};
|
|
||||||
self.scores[team_idx] += points;
|
|
||||||
self.hammer = team.other();
|
self.hammer = team.other();
|
||||||
} else {
|
} else {
|
||||||
points = 0;
|
points = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
self.last_end_scored = Some((self.end, points, scoring_team));
|
let (team1_pts, team2_pts) = match scoring_team {
|
||||||
|
Some(Team::Team1) if points > 0 => (points, 0),
|
||||||
|
Some(Team::Team2) if points > 0 => (0, points),
|
||||||
|
_ => (0, 0),
|
||||||
|
};
|
||||||
|
self.scoreboard.push(EndScore {
|
||||||
|
end: self.end,
|
||||||
|
hammer: end_hammer,
|
||||||
|
team1: team1_pts,
|
||||||
|
team2: team2_pts,
|
||||||
|
});
|
||||||
|
|
||||||
self.phase = GamePhase::EndComplete;
|
self.phase = GamePhase::EndComplete;
|
||||||
self.advance_end_or_finish();
|
self.advance_end_or_finish();
|
||||||
}
|
}
|
||||||
@ -184,8 +197,8 @@ impl Game {
|
|||||||
}
|
}
|
||||||
|
|
||||||
self.end += 1;
|
self.end += 1;
|
||||||
self.stones_red = STONES_PER_TEAM;
|
self.stones_team1 = STONES_PER_TEAM;
|
||||||
self.stones_yellow = STONES_PER_TEAM;
|
self.stones_team2 = STONES_PER_TEAM;
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
self.physics.reset();
|
self.physics.reset();
|
||||||
self.physics.reset_stone_ids();
|
self.physics.reset_stone_ids();
|
||||||
@ -193,20 +206,14 @@ impl Game {
|
|||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
|
|
||||||
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
|
|
||||||
ServerMessage::EndScored { end, points, scoring_team }
|
|
||||||
});
|
|
||||||
self.last_end_scored = None;
|
|
||||||
msg
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn game_state_message(&self) -> ServerMessage {
|
pub fn game_state_message(&self) -> ServerMessage {
|
||||||
ServerMessage::GameState {
|
ServerMessage::GameState {
|
||||||
end: self.end,
|
end: self.end,
|
||||||
scores: self.scores,
|
scores: self.scores,
|
||||||
hammer: self.hammer,
|
hammer: self.hammer,
|
||||||
turn_team: self.turn_team,
|
turn_team: self.turn_team,
|
||||||
|
scoreboard: self.scoreboard.clone(),
|
||||||
|
stones_remaining: [self.stones_team1, self.stones_team2],
|
||||||
stones: self.active_stones.clone(),
|
stones: self.active_stones.clone(),
|
||||||
phase: match self.phase {
|
phase: match self.phase {
|
||||||
GamePhase::Waiting => Phase::Waiting,
|
GamePhase::Waiting => Phase::Waiting,
|
||||||
@ -221,9 +228,9 @@ impl Game {
|
|||||||
|
|
||||||
pub fn game_over_message(&self) -> ServerMessage {
|
pub fn game_over_message(&self) -> ServerMessage {
|
||||||
let winner = if self.scores[0] > self.scores[1] {
|
let winner = if self.scores[0] > self.scores[1] {
|
||||||
Some(Team::Red)
|
Some(Team::Team1)
|
||||||
} else if self.scores[1] > self.scores[0] {
|
} else if self.scores[1] > self.scores[0] {
|
||||||
Some(Team::Yellow)
|
Some(Team::Team2)
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
};
|
};
|
||||||
@ -252,6 +259,7 @@ impl Room {
|
|||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::physics::DRAW_VELOCITY;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn starts_in_waiting_phase() {
|
fn starts_in_waiting_phase() {
|
||||||
@ -266,6 +274,9 @@ mod tests {
|
|||||||
assert!(matches!(game.phase, GamePhase::Playing));
|
assert!(matches!(game.phase, GamePhase::Playing));
|
||||||
assert_eq!(game.end, 1);
|
assert_eq!(game.end, 1);
|
||||||
assert_eq!(game.scores, [0, 0]);
|
assert_eq!(game.scores, [0, 0]);
|
||||||
|
assert!(game.scoreboard.is_empty());
|
||||||
|
assert_eq!(game.stones_team1, STONES_PER_TEAM);
|
||||||
|
assert_eq!(game.stones_team2, STONES_PER_TEAM);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -274,17 +285,21 @@ mod tests {
|
|||||||
game.start();
|
game.start();
|
||||||
let turn = game.turn_team;
|
let turn = game.turn_team;
|
||||||
let wrong = turn.other();
|
let wrong = turn.other();
|
||||||
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
|
let result = game.handle_throw(wrong, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn accepts_throw_for_turn_team() {
|
fn accepts_throw_for_turn_team_with_velocity() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
game.start();
|
game.start();
|
||||||
let turn = game.turn_team;
|
let turn = game.turn_team;
|
||||||
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
|
let result = game.handle_throw(turn, 0.5, 38.7, DRAW_VELOCITY, 1, 1.0);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
|
let paths = result.unwrap();
|
||||||
|
assert!(!paths.is_empty());
|
||||||
|
assert_eq!(paths[0].stone_id.n, 1);
|
||||||
|
assert_eq!(paths[0].team, turn);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@ -293,7 +308,69 @@ mod tests {
|
|||||||
game.start();
|
game.start();
|
||||||
let turn = game.turn_team;
|
let turn = game.turn_team;
|
||||||
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
|
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
|
||||||
let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0);
|
let result = game.handle_throw(turn, 0.0, 30.0, DRAW_VELOCITY, 1, 1.0);
|
||||||
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
|
assert!(
|
||||||
|
result.is_ok(),
|
||||||
|
"broom outside house should be allowed: {:?}",
|
||||||
|
result.err()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn process_throw_has_no_end_scored_and_includes_scoreboard_fields() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
game.start();
|
||||||
|
let turn = game.turn_team;
|
||||||
|
let outcome = game
|
||||||
|
.process_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
// ThrowOutcome must not carry end_scored
|
||||||
|
assert!(!outcome.trajectories.is_empty());
|
||||||
|
match &outcome.state_message {
|
||||||
|
ServerMessage::GameState {
|
||||||
|
scoreboard,
|
||||||
|
stones_remaining,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(scoreboard.is_empty() || !scoreboard.is_empty()); // field present
|
||||||
|
assert_eq!(stones_remaining.len(), 2);
|
||||||
|
// One stone thrown
|
||||||
|
let remaining = stones_remaining[turn.index()];
|
||||||
|
assert_eq!(remaining, STONES_PER_TEAM - 1);
|
||||||
|
}
|
||||||
|
other => panic!("expected GameState, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_state_message_exposes_scoreboard_and_stones_remaining() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
game.start();
|
||||||
|
match game.game_state_message() {
|
||||||
|
ServerMessage::GameState {
|
||||||
|
scoreboard,
|
||||||
|
stones_remaining,
|
||||||
|
stones,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert!(scoreboard.is_empty());
|
||||||
|
assert_eq!(stones_remaining, [STONES_PER_TEAM, STONES_PER_TEAM]);
|
||||||
|
assert!(stones.is_empty());
|
||||||
|
}
|
||||||
|
other => panic!("expected GameState, got {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn decrements_stones_remaining_per_team() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
game.start();
|
||||||
|
let turn = game.turn_team;
|
||||||
|
game.handle_throw(turn, 0.0, HOUSE_CENTER.1, DRAW_VELOCITY, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
match turn {
|
||||||
|
Team::Team1 => assert_eq!(game.stones_team1, STONES_PER_TEAM - 1),
|
||||||
|
Team::Team2 => assert_eq!(game.stones_team2, STONES_PER_TEAM - 1),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -174,22 +174,27 @@ fn spawn_message_handler(
|
|||||||
let text_ref = text.as_str();
|
let text_ref = text.as_str();
|
||||||
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
||||||
match parsed {
|
match parsed {
|
||||||
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
|
Ok(ClientMessage::Throw {
|
||||||
|
team,
|
||||||
|
broom_x,
|
||||||
|
broom_y,
|
||||||
|
velocity,
|
||||||
|
curl,
|
||||||
|
friction,
|
||||||
|
}) => {
|
||||||
let mut room_guard = room.lock().await;
|
let mut room_guard = room.lock().await;
|
||||||
match room_guard
|
match room_guard
|
||||||
.game
|
.game
|
||||||
.process_throw(team, broom_x, broom_y, weight, curl, friction)
|
.process_throw(team, broom_x, broom_y, velocity, curl, friction)
|
||||||
{
|
{
|
||||||
Ok(ThrowOutcome {
|
Ok(ThrowOutcome {
|
||||||
trajectory,
|
trajectories,
|
||||||
end_scored,
|
|
||||||
state_message,
|
state_message,
|
||||||
game_over,
|
game_over,
|
||||||
}) => {
|
}) => {
|
||||||
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
|
let _ = tx.send(ServerMessage::Trajectories {
|
||||||
if let Some(scored) = end_scored {
|
stones: trajectories,
|
||||||
let _ = tx.send(scored);
|
});
|
||||||
}
|
|
||||||
let _ = tx.send(state_message);
|
let _ = tx.send(state_message);
|
||||||
if let Some(over) = game_over {
|
if let Some(over) = game_over {
|
||||||
let _ = tx.send(over);
|
let _ = tx.send(over);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -11,6 +11,8 @@ pub const ENDS: u8 = 10;
|
|||||||
|
|
||||||
// World coordinates in meters, y along sheet toward house.
|
// World coordinates in meters, y along sheet toward house.
|
||||||
pub const SHEET_WIDTH: f32 = 5.0;
|
pub const SHEET_WIDTH: f32 = 5.0;
|
||||||
|
/// Full sheet length (m). Kept for protocol/docs/layout parity with the frontend.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub const SHEET_LENGTH: f32 = 45.0;
|
pub const SHEET_LENGTH: f32 = 45.0;
|
||||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||||
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
||||||
@ -22,23 +24,40 @@ pub const HACK_Y: f32 = 2.0;
|
|||||||
pub const STONE_RADIUS: f32 = 0.15;
|
pub const STONE_RADIUS: f32 = 0.15;
|
||||||
pub const STONE_MASS: f32 = 20.0;
|
pub const STONE_MASS: f32 = 20.0;
|
||||||
pub const STONE_FRICTION: f32 = 0.015;
|
pub const STONE_FRICTION: f32 = 0.015;
|
||||||
pub const STONE_RESTITUTION: f32 = 0.05;
|
/// Newton restitution for stone–stone contacts (Rapier, Average combine).
|
||||||
pub const MIN_SPEED: f32 = 3.0;
|
/// Curling granite is nearly elastic on contact; low e makes takeouts feel like
|
||||||
pub const MAX_SPEED: f32 = 6.45;
|
/// putty (both limp together). ~0.9 → both keep going along impact direction.
|
||||||
|
pub const STONE_RESTITUTION: f32 = 0.9;
|
||||||
|
/// Soft guard end of the throw slider. Weight 1 → MIN_SPEED.
|
||||||
|
/// Calibrated with DRAW_VELOCITY so mid-slider (weight 5) lands near the tee.
|
||||||
|
/// Shared with the frontend; binary sim does not clamp on it (clients send free velocity).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const MIN_SPEED: f32 = 1.9;
|
||||||
|
/// Heavy end of the throw slider. Weight 10 → MAX_SPEED.
|
||||||
|
/// Keep span so weight 5 ≈ DRAW_VELOCITY (2.38).
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub const MAX_SPEED: f32 = 3.0;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum Team {
|
pub enum Team {
|
||||||
#[default]
|
#[default]
|
||||||
Red,
|
Team1,
|
||||||
Yellow,
|
Team2,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Team {
|
impl Team {
|
||||||
pub fn other(self) -> Self {
|
pub fn other(self) -> Self {
|
||||||
match self {
|
match self {
|
||||||
Team::Red => Team::Yellow,
|
Team::Team1 => Team::Team2,
|
||||||
Team::Yellow => Team::Red,
|
Team::Team2 => Team::Team1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn index(self) -> usize {
|
||||||
|
match self {
|
||||||
|
Team::Team1 => 0,
|
||||||
|
Team::Team2 => 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -46,12 +65,19 @@ impl Team {
|
|||||||
impl fmt::Display for Team {
|
impl fmt::Display for Team {
|
||||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
match self {
|
match self {
|
||||||
Team::Red => write!(f, "red"),
|
Team::Team1 => write!(f, "team1"),
|
||||||
Team::Yellow => write!(f, "yellow"),
|
Team::Team2 => write!(f, "team2"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Per-team stone number within an end (`n` is 1..=8).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||||
|
pub struct StoneId {
|
||||||
|
pub team: Team,
|
||||||
|
pub n: u8,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
@ -59,7 +85,8 @@ pub enum ClientMessage {
|
|||||||
team: Team,
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
/// Initial speed in m/s (not legacy weight).
|
||||||
|
velocity: f32,
|
||||||
#[serde(default = "default_curl")]
|
#[serde(default = "default_curl")]
|
||||||
curl: i8,
|
curl: i8,
|
||||||
#[serde(default = "default_friction")]
|
#[serde(default = "default_friction")]
|
||||||
@ -67,8 +94,20 @@ pub enum ClientMessage {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_curl() -> i8 { 1 }
|
fn default_curl() -> i8 {
|
||||||
fn default_friction() -> f32 { 1.0 }
|
1
|
||||||
|
}
|
||||||
|
fn default_friction() -> f32 {
|
||||||
|
1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct EndScore {
|
||||||
|
pub end: u8,
|
||||||
|
pub hammer: Team,
|
||||||
|
pub team1: i32,
|
||||||
|
pub team2: i32,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
@ -77,16 +116,17 @@ pub enum ServerMessage {
|
|||||||
Waiting { message: String },
|
Waiting { message: String },
|
||||||
GameState {
|
GameState {
|
||||||
end: u8,
|
end: u8,
|
||||||
scores: [i32; 2], // red, yellow
|
scores: [i32; 2], // team1, team2
|
||||||
hammer: Team,
|
hammer: Team,
|
||||||
turn_team: Team,
|
turn_team: Team,
|
||||||
|
scoreboard: Vec<EndScore>,
|
||||||
|
stones_remaining: [u8; 2],
|
||||||
stones: Vec<StoneState>,
|
stones: Vec<StoneState>,
|
||||||
phase: Phase,
|
phase: Phase,
|
||||||
},
|
},
|
||||||
Trajectory {
|
Trajectories {
|
||||||
paths: Vec<StoneTrajectory>,
|
stones: Vec<StonePath>,
|
||||||
},
|
},
|
||||||
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
|
||||||
GameOver {
|
GameOver {
|
||||||
scores: [i32; 2],
|
scores: [i32; 2],
|
||||||
winner: Option<Team>,
|
winner: Option<Team>,
|
||||||
@ -106,18 +146,127 @@ pub enum Phase {
|
|||||||
GameComplete,
|
GameComplete,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One stone's sampled path for client animation.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoneTrajectory {
|
pub struct StonePath {
|
||||||
pub stone_id: u32,
|
pub stone_id: StoneId,
|
||||||
pub path: Vec<(f32, f32, f32)>,
|
pub rotation: f32,
|
||||||
|
pub team: Team,
|
||||||
|
/// Samples as [x, y, theta]. Time is sample_index / SAMPLE_RATE_HZ on the client.
|
||||||
|
pub trajectory: Vec<[f32; 3]>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoneState {
|
pub struct StoneState {
|
||||||
pub id: u32,
|
pub id: StoneId,
|
||||||
pub team: Team,
|
pub team: Team,
|
||||||
pub x: f32,
|
pub x: f32,
|
||||||
pub y: f32,
|
pub y: f32,
|
||||||
pub rotation: f32,
|
pub rotation: f32,
|
||||||
pub active: bool,
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn team_serializes_as_team1_team2() {
|
||||||
|
assert_eq!(serde_json::to_string(&Team::Team1).unwrap(), "\"team1\"");
|
||||||
|
assert_eq!(serde_json::to_string(&Team::Team2).unwrap(), "\"team2\"");
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Team>("\"team1\"").unwrap(),
|
||||||
|
Team::Team1
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_str::<Team>("\"team2\"").unwrap(),
|
||||||
|
Team::Team2
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn throw_message_uses_velocity_not_weight() {
|
||||||
|
let json = r#"{"type":"throw","team":"team1","broom_x":0.5,"broom_y":38.5,"velocity":4.2,"curl":1,"friction":1.0}"#;
|
||||||
|
let msg: ClientMessage = serde_json::from_str(json).unwrap();
|
||||||
|
match msg {
|
||||||
|
ClientMessage::Throw {
|
||||||
|
team,
|
||||||
|
velocity,
|
||||||
|
broom_x,
|
||||||
|
..
|
||||||
|
} => {
|
||||||
|
assert_eq!(team, Team::Team1);
|
||||||
|
assert!((velocity - 4.2).abs() < 1e-5);
|
||||||
|
assert!((broom_x - 0.5).abs() < 1e-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn trajectories_type_tag_and_stone_path_shape() {
|
||||||
|
let msg = ServerMessage::Trajectories {
|
||||||
|
stones: vec![StonePath {
|
||||||
|
stone_id: StoneId {
|
||||||
|
team: Team::Team1,
|
||||||
|
n: 1,
|
||||||
|
},
|
||||||
|
rotation: 0.5,
|
||||||
|
team: Team::Team1,
|
||||||
|
trajectory: vec![[0.0, 2.0, 0.0], [0.1, 3.0, 0.1]],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&msg).unwrap();
|
||||||
|
assert_eq!(json["type"], "trajectories");
|
||||||
|
assert_eq!(json["stones"][0]["stone_id"]["team"], "team1");
|
||||||
|
assert_eq!(json["stones"][0]["stone_id"]["n"], 1);
|
||||||
|
assert_eq!(json["stones"][0]["trajectory"][0], serde_json::json!([0.0, 2.0, 0.0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn game_state_includes_scoreboard_and_stones_remaining() {
|
||||||
|
let msg = ServerMessage::GameState {
|
||||||
|
end: 2,
|
||||||
|
scores: [1, 0],
|
||||||
|
hammer: Team::Team2,
|
||||||
|
turn_team: Team::Team1,
|
||||||
|
scoreboard: vec![EndScore {
|
||||||
|
end: 1,
|
||||||
|
hammer: Team::Team1,
|
||||||
|
team1: 1,
|
||||||
|
team2: 0,
|
||||||
|
}],
|
||||||
|
stones_remaining: [7, 8],
|
||||||
|
stones: vec![StoneState {
|
||||||
|
id: StoneId {
|
||||||
|
team: Team::Team1,
|
||||||
|
n: 1,
|
||||||
|
},
|
||||||
|
team: Team::Team1,
|
||||||
|
x: 0.0,
|
||||||
|
y: 38.0,
|
||||||
|
rotation: 0.0,
|
||||||
|
}],
|
||||||
|
phase: Phase::Playing,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&msg).unwrap();
|
||||||
|
assert_eq!(json["type"], "game_state");
|
||||||
|
assert_eq!(json["scoreboard"][0]["end"], 1);
|
||||||
|
assert_eq!(json["stones_remaining"], serde_json::json!([7, 8]));
|
||||||
|
assert!(json.get("active").is_none());
|
||||||
|
assert_eq!(json["stones"][0]["id"]["n"], 1);
|
||||||
|
// StoneState must not include active
|
||||||
|
assert!(json["stones"][0].get("active").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn end_scored_variant_is_gone() {
|
||||||
|
// Ensure we never serialize a legacy end_scored type tag from ServerMessage.
|
||||||
|
let over = ServerMessage::GameOver {
|
||||||
|
scores: [5, 3],
|
||||||
|
winner: Some(Team::Team1),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&over).unwrap();
|
||||||
|
assert_eq!(json["type"], "game_over");
|
||||||
|
assert_ne!(json["type"], "end_scored");
|
||||||
|
assert_ne!(json["type"], "trajectory");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,14 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
|
const TAKEOUT_VEL = DRAW_VEL * 1.4 // ~3.33 — enough to move the stationary stone
|
||||||
const room = 'COLQA' + Math.floor(Math.random() * 1000)
|
const room = 'COLQA' + Math.floor(Math.random() * 1000)
|
||||||
const url = 'ws://127.0.0.1:3000/ws?room=' + room
|
const url = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect() {
|
function connect() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
@ -24,46 +31,83 @@ function waitFor(messages, pred, timeout = 10000) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestState(messages) {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'game_state') return messages[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function stoneIdKey(id) {
|
||||||
|
return `${id.team}:${id.n}`
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const c = await connect()
|
const c = await connect()
|
||||||
await waitFor(c.messages, () => {
|
await waitFor(c.messages, () => {
|
||||||
const last = c.messages[c.messages.length - 1]
|
const st = latestState(c.messages)
|
||||||
return last && last.type === 'game_state' && last.phase === 'playing'
|
return st && st.phase === 'playing'
|
||||||
})
|
})
|
||||||
|
|
||||||
// First throw: weight 7 so it stays in house.
|
// First throw: DRAW_VEL so it stays in house.
|
||||||
let state = c.messages[c.messages.length - 1]
|
let state = latestState(c.messages)
|
||||||
c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.0, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 }))
|
const firstTeam = state.turn_team
|
||||||
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1)
|
throwFor(c.ws, firstTeam, 0.0, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
state = c.messages[c.messages.length - 1]
|
await waitFor(c.messages, () => {
|
||||||
|
const st = latestState(c.messages)
|
||||||
|
return st && st.stones && st.stones.length === 1 && st.phase === 'playing'
|
||||||
|
}, 15000)
|
||||||
|
state = latestState(c.messages)
|
||||||
console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y })))
|
console.log('After first throw stones:', state.stones.map(s => ({ id: s.id, x: s.x, y: s.y })))
|
||||||
if (state.stones.length !== 1) throw new Error('expected first stone in play')
|
if (state.stones.length !== 1) throw new Error('expected first stone in play')
|
||||||
|
|
||||||
const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length
|
const firstStone = state.stones[0]
|
||||||
console.log('trajectory count before second throw:', trajCountBefore)
|
const firstIdKey = stoneIdKey(firstStone.id)
|
||||||
|
const trajCountBefore = c.messages.filter(m => m.type === 'trajectories').length
|
||||||
|
console.log('trajectories count before second throw:', trajCountBefore)
|
||||||
|
|
||||||
// Second throw aimed slightly off-center so it hits the first stone.
|
// Second throw aimed at first stone so they collide.
|
||||||
c.ws.send(JSON.stringify({ type: 'throw', team: state.turn_team, broom_x: 0.25, broom_y: 38.5, weight: 7, curl: 0, friction: 1.0 }))
|
const secondTeam = state.turn_team
|
||||||
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore)
|
throwFor(c.ws, secondTeam, firstStone.x, firstStone.y, TAKEOUT_VEL, 0, 1.0)
|
||||||
|
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectories').length > trajCountBefore, 15000)
|
||||||
|
|
||||||
const traj = c.messages.filter(m => m.type === 'trajectory').pop()
|
const traj = c.messages.filter(m => m.type === 'trajectories').pop()
|
||||||
console.log('Trajectory paths count:', traj.paths.length)
|
if (!traj.stones || !Array.isArray(traj.stones)) {
|
||||||
for (const p of traj.paths) {
|
throw new Error('trajectories message must have stones[]')
|
||||||
console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1])
|
}
|
||||||
|
console.log('Trajectory stones count:', traj.stones.length)
|
||||||
|
for (const p of traj.stones) {
|
||||||
|
console.log(
|
||||||
|
'stone_id', p.stone_id,
|
||||||
|
'team', p.team,
|
||||||
|
'path length', p.trajectory?.length,
|
||||||
|
'first', p.trajectory?.[0],
|
||||||
|
'last', p.trajectory?.[p.trajectory.length - 1]
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b)
|
const ids = traj.stones.map(p => stoneIdKey(p.stone_id)).sort()
|
||||||
if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids))
|
if (ids.length < 2) {
|
||||||
for (const p of traj.paths) {
|
throw new Error('expected both stones in trajectories, got ' + JSON.stringify(ids))
|
||||||
if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id)
|
}
|
||||||
|
if (!ids.includes(firstIdKey)) {
|
||||||
|
throw new Error('expected first stone in trajectories, got ' + JSON.stringify(ids))
|
||||||
|
}
|
||||||
|
for (const p of traj.stones) {
|
||||||
|
if (!p.trajectory || p.trajectory.length < 5) {
|
||||||
|
throw new Error('path too short for stone ' + JSON.stringify(p.stone_id))
|
||||||
|
}
|
||||||
|
if (typeof p.stone_id !== 'object' || !p.stone_id.team || typeof p.stone_id.n !== 'number') {
|
||||||
|
throw new Error('stone_id must be {team,n}, got ' + JSON.stringify(p.stone_id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify the first stone actually moved because of collision.
|
// Verify the first stone actually moved because of collision.
|
||||||
const stone1Path = traj.paths.find(p => p.stone_id === 1).path
|
const stone1Path = traj.stones.find(p => stoneIdKey(p.stone_id) === firstIdKey).trajectory
|
||||||
const first = stone1Path[0]
|
const first = stone1Path[0]
|
||||||
const last = stone1Path[stone1Path.length - 1]
|
const last = stone1Path[stone1Path.length - 1]
|
||||||
const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2)
|
const dist = Math.sqrt((last[0] - first[0]) ** 2 + (last[1] - first[1]) ** 2)
|
||||||
console.log('stone1 moved', dist, 'm')
|
console.log('first stone moved', dist, 'm')
|
||||||
if (dist < 0.05) throw new Error('expected first stone to move after collision')
|
if (dist < 0.05) throw new Error('expected first stone to move after collision')
|
||||||
|
|
||||||
console.log('COLLISION TRAJECTORY QA PASSED')
|
console.log('COLLISION TRAJECTORY QA PASSED')
|
||||||
|
|||||||
@ -1,7 +1,12 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}`
|
const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}`
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect(name, room) {
|
function connect(name, room) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(base(room))
|
const ws = new WebSocket(base(room))
|
||||||
@ -27,37 +32,80 @@ function waitFor(messages, pred, timeout = 30000) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestState(messages) {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'game_state') return messages[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const room = 'ENDQA' + Math.floor(Math.random() * 1000)
|
const room = 'ENDQA' + Math.floor(Math.random() * 1000)
|
||||||
const p1 = await connect('p1', room)
|
const p1 = await connect('p1', room)
|
||||||
const p2 = await connect('p2', room)
|
const p2 = await connect('p2', room)
|
||||||
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000)
|
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000)
|
||||||
|
|
||||||
const getTurn = () => {
|
const initial = latestState(p1.messages)
|
||||||
const st = p1.messages.slice(-1)[0]
|
const startEnd = initial.end
|
||||||
return st && st.type === 'game_state' ? st.turn_team : null
|
const startScoreboardLen = (initial.scoreboard || []).length
|
||||||
}
|
console.log('start end=', startEnd, 'scoreboard len=', startScoreboardLen)
|
||||||
|
|
||||||
let stateCount = p1.messages.filter(m => m.type === 'game_state').length
|
|
||||||
|
|
||||||
|
// 16 throws (8 per team) complete one end. NO end_scored message —
|
||||||
|
// assert scoreboard length increase + end advance on game_state.
|
||||||
for (let i = 0; i < 16; i++) {
|
for (let i = 0; i < 16; i++) {
|
||||||
await waitFor(p1.messages, () => {
|
await waitFor(p1.messages, () => {
|
||||||
const last = p1.messages.slice(-1)[0]
|
const st = latestState(p1.messages)
|
||||||
return last && last.type === 'game_state' && last.phase === 'playing'
|
return st && st.phase === 'playing'
|
||||||
}, 5000)
|
}, 15000)
|
||||||
const turn = getTurn()
|
const st = latestState(p1.messages)
|
||||||
|
// If end already advanced mid-loop, stop early.
|
||||||
|
if ((st.scoreboard || []).length > startScoreboardLen && st.end > startEnd) {
|
||||||
|
console.log('end advanced early at throw', i)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
const turn = st.turn_team
|
||||||
if (!turn) throw new Error('no turn')
|
if (!turn) throw new Error('no turn')
|
||||||
const broomX = (Math.random() - 0.5) * 0.6
|
// Keep broom near house center so draws stay in play for scoring.
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
const broomX = ((i % 8) - 3.5) * 0.08
|
||||||
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
||||||
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
|
throwFor(p1.ws, turn, broomX, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
|
await waitFor(p1.messages, () => {
|
||||||
|
const n = p1.messages.filter(m => m.type === 'game_state').length
|
||||||
|
const err = p1.messages.filter(m => m.type === 'error').pop()
|
||||||
|
if (err && n <= prevStateCount) throw new Error('throw error: ' + err.message)
|
||||||
|
return n > prevStateCount
|
||||||
|
}, 30000)
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'end_scored'), 20000)
|
// Wait for scoreboard entry + end advance (no end_scored type).
|
||||||
const final = p1.messages.slice(-1)[0]
|
await waitFor(p1.messages, () => {
|
||||||
console.log('Final game_state:', final)
|
const st = latestState(p1.messages)
|
||||||
if (final.end <= 1) throw new Error('end did not advance')
|
if (!st) return false
|
||||||
console.log('End scored event received; end advanced to', final.end)
|
const sb = st.scoreboard || []
|
||||||
|
return sb.length > startScoreboardLen && st.end > startEnd
|
||||||
|
}, 30000)
|
||||||
|
|
||||||
|
// Ensure we never saw legacy end_scored
|
||||||
|
if (p1.messages.some(m => m.type === 'end_scored')) {
|
||||||
|
throw new Error('legacy end_scored message must not appear')
|
||||||
|
}
|
||||||
|
|
||||||
|
const final = latestState(p1.messages)
|
||||||
|
console.log('Final game_state:', {
|
||||||
|
end: final.end,
|
||||||
|
scores: final.scores,
|
||||||
|
scoreboard: final.scoreboard,
|
||||||
|
phase: final.phase,
|
||||||
|
})
|
||||||
|
if (!final.scoreboard || final.scoreboard.length < 1) {
|
||||||
|
throw new Error('expected scoreboard entry after end')
|
||||||
|
}
|
||||||
|
const entry = final.scoreboard[0]
|
||||||
|
if (typeof entry.end !== 'number' || entry.end < 1) {
|
||||||
|
throw new Error(`bad scoreboard entry: ${JSON.stringify(entry)}`)
|
||||||
|
}
|
||||||
|
if (final.end <= startEnd) throw new Error('end did not advance')
|
||||||
|
console.log('Scoreboard entry received; end advanced to', final.end)
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
p2.ws.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
const room = 'MULTI' + Math.floor(Math.random() * 1000)
|
const room = 'MULTI' + Math.floor(Math.random() * 1000)
|
||||||
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect(name) {
|
function connect(name) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(base)
|
const ws = new WebSocket(base)
|
||||||
@ -49,16 +54,15 @@ function waitFor(client, pred, timeout = 10000) {
|
|||||||
const state = p1.messages.find(m => m.type === 'game_state')
|
const state = p1.messages.find(m => m.type === 'game_state')
|
||||||
const turn = state.turn_team
|
const turn = state.turn_team
|
||||||
console.log(`p1 throwing for team ${turn}`)
|
console.log(`p1 throwing for team ${turn}`)
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
throwFor(p1.ws, turn, 0.0, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
|
|
||||||
// All 3 clients eventually see trajectory or updated game_state
|
// All 3 clients eventually see trajectories (plural) or updated game_state
|
||||||
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
|
||||||
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
|
||||||
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectories'), 15000)
|
||||||
console.log('All 3 clients received trajectory')
|
console.log('All 3 clients received trajectories')
|
||||||
|
|
||||||
// All 3 see an updated game_state after the throw
|
// All 3 see an updated game_state after the throw
|
||||||
const lastIdx = p1.messages.length - 1
|
|
||||||
await waitFor(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(p1, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await waitFor(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
@ -72,4 +76,4 @@ function waitFor(client, pred, timeout = 10000) {
|
|||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
const room = 'PERSIST' + Math.floor(Math.random() * 1000)
|
const room = 'PERSIST' + Math.floor(Math.random() * 1000)
|
||||||
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect(name) {
|
function connect(name) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(base)
|
const ws = new WebSocket(base)
|
||||||
@ -36,39 +41,61 @@ function latestState(messages) {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stoneIdKey(id) {
|
||||||
|
return `${id.team}:${id.n}`
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
|
|
||||||
// Determine the current player and throw two stones in the same end.
|
|
||||||
let state = latestState(p1.messages)
|
let state = latestState(p1.messages)
|
||||||
console.log('Initial state', state)
|
console.log('Initial state', {
|
||||||
|
turn_team: state.turn_team,
|
||||||
|
stones_remaining: state.stones_remaining,
|
||||||
|
scoreboard: state.scoreboard,
|
||||||
|
})
|
||||||
|
|
||||||
// First throw: current turn team.
|
// First throw: DRAW_VEL + curl 0 + house center keeps stone in play.
|
||||||
let turn = state.turn_team
|
let turn = state.turn_team
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
const firstTeam = turn
|
||||||
|
throwFor(p1.ws, turn, -0.3, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
console.log('After first throw:', state)
|
console.log('After first throw:', state.stones)
|
||||||
|
|
||||||
// Second throw: other team.
|
// Second throw: other team, offset so both stay.
|
||||||
turn = state.turn_team
|
turn = state.turn_team
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
|
const secondTeam = turn
|
||||||
|
throwFor(p1.ws, turn, 0.3, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
console.log('After second throw:', state)
|
console.log('After second throw:', state.stones)
|
||||||
|
|
||||||
if (state.stones.length !== 2) throw new Error(`Expected 2 stones after second throw, got ${state.stones.length}`)
|
if (state.stones.length !== 2) throw new Error(`Expected 2 stones after second throw, got ${state.stones.length}`)
|
||||||
|
|
||||||
// Check that ids are monotonic.
|
// Stone ids are {team, n}, not flat numbers — each team's first stone is n=1.
|
||||||
const ids = state.stones.map(s => s.id).sort((a, b) => a - b)
|
const ids = state.stones.map(s => stoneIdKey(s.id)).sort()
|
||||||
if (ids[0] !== 1 || ids[1] !== 2) throw new Error(`Unexpected stone ids: ${ids}`)
|
const expected = [stoneIdKey({ team: firstTeam, n: 1 }), stoneIdKey({ team: secondTeam, n: 1 })].sort()
|
||||||
|
if (ids[0] !== expected[0] || ids[1] !== expected[1]) {
|
||||||
|
throw new Error(`Unexpected stone ids: ${JSON.stringify(ids)} expected ${JSON.stringify(expected)}`)
|
||||||
|
}
|
||||||
|
for (const s of state.stones) {
|
||||||
|
if (typeof s.id !== 'object' || !s.id.team || typeof s.id.n !== 'number') {
|
||||||
|
throw new Error(`Stone id must be {team,n}, got ${JSON.stringify(s.id)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Both stones should be in play (near house).
|
// Both stones should be in play (near house).
|
||||||
for (const s of state.stones) {
|
for (const s of state.stones) {
|
||||||
if (s.y < 35 || s.y > 42) throw new Error(`Stone ${s.id} is out of house: y=${s.y}`)
|
if (s.y < 35 || s.y > 42) throw new Error(`Stone ${stoneIdKey(s.id)} is out of house: y=${s.y}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(state.scoreboard)) throw new Error('scoreboard missing')
|
||||||
|
if (!Array.isArray(state.stones_remaining) || state.stones_remaining.length !== 2) {
|
||||||
|
throw new Error(`stones_remaining must be [u8,u8], got ${JSON.stringify(state.stones_remaining)}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('PERSISTENCE E2E PASSED')
|
console.log('PERSISTENCE E2E PASSED')
|
||||||
|
|||||||
@ -1,6 +1,11 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA'
|
const DRAW_VEL = 2.38
|
||||||
|
const base = 'ws://127.0.0.1:3000/ws?room=SCOREQA' + Math.floor(Math.random() * 10000)
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect(name) {
|
function connect(name) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
@ -27,25 +32,41 @@ function waitFor(condFn, timeout = 5000) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function latestState(messages) {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'game_state') return messages[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
let state = p1.messages.find(m => m.type === 'game_state')
|
let state = latestState(p1.messages)
|
||||||
console.log('start turn', state.turn_team, 'hammer', state.hammer)
|
console.log('start turn', state.turn_team, 'hammer', state.hammer, 'stones_remaining', state.stones_remaining)
|
||||||
|
|
||||||
const thrower1 = state.turn_team
|
const thrower1 = state.turn_team
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
throwFor(p1.ws, thrower1, 0.2, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => {
|
||||||
await new Promise(r => setTimeout(r, 500))
|
const s = latestState(p1.messages)
|
||||||
state = p1.messages.slice(-1)[0]
|
return s && s.stones && s.stones.length >= 1 && s.phase === 'playing'
|
||||||
console.log('After first throw:', state)
|
}, 15000)
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
state = latestState(p1.messages)
|
||||||
|
console.log('After first throw stones:', state.stones.length, 'remaining', state.stones_remaining)
|
||||||
|
|
||||||
const thrower2 = state.turn_team
|
const thrower2 = state.turn_team
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
|
throwFor(p1.ws, thrower2, -0.2, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
|
await waitFor(() => p1.messages.filter(m => m.type === 'trajectories').length >= 2, 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => {
|
||||||
await new Promise(r => setTimeout(r, 500))
|
const s = latestState(p1.messages)
|
||||||
console.log('Final stones', p1.messages.slice(-1)[0].stones)
|
return s && s.stones && s.stones.length >= 2 && s.phase === 'playing'
|
||||||
|
}, 15000)
|
||||||
|
await new Promise(r => setTimeout(r, 200))
|
||||||
|
state = latestState(p1.messages)
|
||||||
|
console.log('Final stones', state.stones)
|
||||||
|
console.log('scoreboard', state.scoreboard, 'stones_remaining', state.stones_remaining)
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(e => {
|
})().catch(e => {
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
const room = 'QA' + Math.floor(Math.random() * 1000)
|
const room = 'QA' + Math.floor(Math.random() * 1000)
|
||||||
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
const base = 'ws://127.0.0.1:3000/ws?room=' + room
|
||||||
|
|
||||||
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
function connect(name) {
|
function connect(name) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(base)
|
const ws = new WebSocket(base)
|
||||||
@ -37,13 +42,25 @@ function waitFor(condFn, timeout = 5000) {
|
|||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
const state = p1.messages.find(m => m.type === 'game_state')
|
const state = p1.messages.find(m => m.type === 'game_state')
|
||||||
console.log('Game state', state)
|
console.log('Game state', {
|
||||||
|
end: state.end,
|
||||||
|
turn_team: state.turn_team,
|
||||||
|
stones_remaining: state.stones_remaining,
|
||||||
|
scoreboard: state.scoreboard,
|
||||||
|
})
|
||||||
|
|
||||||
const turn = state.turn_team
|
const turn = state.turn_team
|
||||||
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
// DRAW_VEL + broom at house center keeps stone in play
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
throwFor(p1.ws, turn, 0.0, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectories'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
console.log('Final state after throw:', p1.messages.slice(-1)[0])
|
const final = p1.messages.slice(-1)[0]
|
||||||
|
console.log('Final state after throw:', {
|
||||||
|
type: final.type,
|
||||||
|
turn_team: final.turn_team,
|
||||||
|
stones: final.stones,
|
||||||
|
stones_remaining: final.stones_remaining,
|
||||||
|
})
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
|
|||||||
@ -1,28 +1,84 @@
|
|||||||
const WebSocket = require('ws')
|
const WebSocket = require('ws')
|
||||||
|
|
||||||
|
const DRAW_VEL = 2.38
|
||||||
const ROOMS = 10
|
const ROOMS = 10
|
||||||
const BASE = 'ws://127.0.0.1:3000/ws?room='
|
const BASE = 'ws://127.0.0.1:3000/ws?room='
|
||||||
|
|
||||||
function connect(name, room) {
|
function throwFor(ws, team, broom_x, broom_y, velocity = DRAW_VEL, curl = 0, friction = 1.0) {
|
||||||
|
ws.send(JSON.stringify({ type: 'throw', team, broom_x, broom_y, velocity, curl, friction }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(room) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const ws = new WebSocket(BASE + room)
|
const ws = new WebSocket(BASE + room)
|
||||||
ws.on('open', () => resolve(ws))
|
const messages = []
|
||||||
|
ws.on('open', () => resolve({ ws, messages }))
|
||||||
ws.on('error', reject)
|
ws.on('error', reject)
|
||||||
ws.on('message', () => {})
|
ws.on('message', (data) => {
|
||||||
|
try {
|
||||||
|
messages.push(JSON.parse(data.toString()))
|
||||||
|
} catch (_) {}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitFor(messages, pred, timeout = 20000) {
|
||||||
|
const start = Date.now()
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const check = () => {
|
||||||
|
if (pred()) return resolve(undefined)
|
||||||
|
if (Date.now() - start > timeout) {
|
||||||
|
const errs = messages.filter(m => m.type === 'error')
|
||||||
|
return reject(new Error(
|
||||||
|
'timeout types=' + messages.map(m => m.type).join(',') +
|
||||||
|
' errs=' + JSON.stringify(errs)
|
||||||
|
))
|
||||||
|
}
|
||||||
|
setTimeout(check, 50)
|
||||||
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function latestState(messages) {
|
||||||
|
for (let i = messages.length - 1; i >= 0; i--) {
|
||||||
|
if (messages[i].type === 'game_state') return messages[i]
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function remainingSum(st) {
|
||||||
|
if (!st || !Array.isArray(st.stones_remaining)) return 16
|
||||||
|
return st.stones_remaining[0] + st.stones_remaining[1]
|
||||||
|
}
|
||||||
|
|
||||||
async function runRoom(i) {
|
async function runRoom(i) {
|
||||||
const room = `LOAD${i}`
|
const room = `LOAD${i}_${process.hrtime.bigint()}`
|
||||||
const p1 = await connect('p1', room)
|
const p1 = await connect(room)
|
||||||
const p2 = await connect('p2', room)
|
const p2 = await connect(room)
|
||||||
await new Promise(r => setTimeout(r, 200))
|
await waitFor(p1.messages, () => {
|
||||||
p1.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 5, curl: 1, friction: 1.0 }))
|
const st = latestState(p1.messages)
|
||||||
await new Promise(r => setTimeout(r, 800))
|
return st && st.phase === 'playing'
|
||||||
p2.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 5, curl: 1, friction: 1.0 }))
|
}, 8000)
|
||||||
await new Promise(r => setTimeout(r, 1000))
|
|
||||||
p1.close()
|
let st = latestState(p1.messages)
|
||||||
p2.close()
|
const gsBefore = p1.messages.filter(m => m.type === 'game_state').length
|
||||||
|
throwFor(p1.ws, st.turn_team, 0.2, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
|
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'trajectories'), 20000)
|
||||||
|
// Must wait for post-throw game_state (remaining decreased), not the pre-throw playing state.
|
||||||
|
await waitFor(p1.messages, () => {
|
||||||
|
const s = latestState(p1.messages)
|
||||||
|
return s && s.phase === 'playing' && remainingSum(s) < 16 &&
|
||||||
|
p1.messages.filter(m => m.type === 'game_state').length > gsBefore
|
||||||
|
}, 20000)
|
||||||
|
|
||||||
|
st = latestState(p1.messages)
|
||||||
|
const trajBefore = p1.messages.filter(m => m.type === 'trajectories').length
|
||||||
|
throwFor(p2.ws, st.turn_team, -0.2, 38.5, DRAW_VEL, 0, 1.0)
|
||||||
|
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'trajectories').length > trajBefore, 20000)
|
||||||
|
|
||||||
|
p1.ws.close()
|
||||||
|
p2.ws.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
@ -30,4 +86,7 @@ async function runRoom(i) {
|
|||||||
await Promise.all(Array.from({ length: ROOMS }, (_, i) => runRoom(i)))
|
await Promise.all(Array.from({ length: ROOMS }, (_, i) => runRoom(i)))
|
||||||
console.log(`10 rooms played start-to-throw-to-close in ${Date.now() - start}ms`)
|
console.log(`10 rooms played start-to-throw-to-close in ${Date.now() - start}ms`)
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})()
|
})().catch((e) => {
|
||||||
|
console.error(e)
|
||||||
|
process.exit(1)
|
||||||
|
})
|
||||||
|
|||||||
@ -1,46 +1,74 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import {
|
import {
|
||||||
|
hogTrimStartIndex,
|
||||||
|
sampleTime,
|
||||||
trimPathToStartAtHogLine,
|
trimPathToStartAtHogLine,
|
||||||
velocityToWeight,
|
velocityToWeight,
|
||||||
weightToVelocity,
|
weightToVelocity,
|
||||||
} from './game-helpers'
|
} from './game-helpers'
|
||||||
|
import { SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
describe('trimPathToStartAtHogLine', () => {
|
describe('trimPathToStartAtHogLine', () => {
|
||||||
it('trims path at the first hog-line crossing and zeroes time', () => {
|
it('trims path at the first hog-line crossing and preserves theta', () => {
|
||||||
|
// (x, y, theta) — time is derived from index after trim
|
||||||
const path: [number, number, number][] = [
|
const path: [number, number, number][] = [
|
||||||
[0, 2, 0],
|
[0, 2, 0.1],
|
||||||
[0, 20, 1],
|
[0, 20, 0.2],
|
||||||
[0, 21.5, 2],
|
[0, 21.5, 0.3],
|
||||||
[0, 30, 3],
|
[0, 30, 0.4],
|
||||||
]
|
]
|
||||||
const trimmed = trimPathToStartAtHogLine(path)
|
const trimmed = trimPathToStartAtHogLine(path)
|
||||||
expect(trimmed[0][1]).toBe(20)
|
expect(trimmed[0][1]).toBe(20)
|
||||||
expect(trimmed[0][2]).toBe(0)
|
expect(trimmed[0][2]).toBe(0.2)
|
||||||
expect(trimmed[trimmed.length - 1][2]).toBe(2)
|
expect(trimmed[trimmed.length - 1][2]).toBe(0.4)
|
||||||
|
expect(trimmed).toHaveLength(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns full path when hog line is never reached', () => {
|
it('returns full path when hog line is never reached', () => {
|
||||||
const path: [number, number, number][] = [
|
const path: [number, number, number][] = [
|
||||||
[0, 2, 0],
|
[0, 2, 0],
|
||||||
[0, 10, 1],
|
[0, 10, 0.5],
|
||||||
]
|
]
|
||||||
expect(trimPathToStartAtHogLine(path)).toEqual(path)
|
expect(trimPathToStartAtHogLine(path)).toEqual(path)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('velocity ↔ weight', () => {
|
describe('sampleTime', () => {
|
||||||
it('maps endpoints correctly', () => {
|
it('is index / SAMPLE_RATE_HZ', () => {
|
||||||
expect(velocityToWeight(3.0)).toBe(1)
|
expect(sampleTime(0)).toBe(0)
|
||||||
expect(velocityToWeight(6.45)).toBe(10)
|
expect(sampleTime(SAMPLE_RATE_HZ)).toBe(1)
|
||||||
expect(weightToVelocity(1)).toBe(3.0)
|
expect(sampleTime(1)).toBeCloseTo(1 / SAMPLE_RATE_HZ)
|
||||||
expect(weightToVelocity(10)).toBe(6.45)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps out-of-range inputs', () => {
|
|
||||||
expect(velocityToWeight(2.5)).toBe(1)
|
|
||||||
expect(velocityToWeight(7.0)).toBe(10)
|
|
||||||
expect(weightToVelocity(0)).toBe(3.0)
|
|
||||||
expect(weightToVelocity(11)).toBe(6.45)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('hogTrimStartIndex', () => {
|
||||||
|
it('returns index just before hog crossing', () => {
|
||||||
|
const path: [number, number, number][] = [
|
||||||
|
[0, 2, 0],
|
||||||
|
[0, 20, 0],
|
||||||
|
[0, 21.5, 0],
|
||||||
|
]
|
||||||
|
expect(hogTrimStartIndex(path)).toBe(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('velocity ↔ weight', () => {
|
||||||
|
it('maps endpoints correctly', () => {
|
||||||
|
expect(velocityToWeight(1.9)).toBe(1)
|
||||||
|
expect(velocityToWeight(3.0)).toBe(10)
|
||||||
|
expect(weightToVelocity(1)).toBe(1.9)
|
||||||
|
expect(weightToVelocity(10)).toBe(3.0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('mid weight is near draw (tee-line) velocity', () => {
|
||||||
|
// weight 5 → 1.9 + 4/9 * 1.1 ≈ 2.389 — calibrated DRAW_VELOCITY
|
||||||
|
expect(weightToVelocity(5)).toBeCloseTo(2.389, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps out-of-range inputs', () => {
|
||||||
|
expect(velocityToWeight(1.5)).toBe(1)
|
||||||
|
expect(velocityToWeight(4.0)).toBe(10)
|
||||||
|
expect(weightToVelocity(0)).toBe(1.9)
|
||||||
|
expect(weightToVelocity(11)).toBe(3.0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
@ -1,4 +1,9 @@
|
|||||||
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED } from './protocol'
|
import { HOG_LINE_Y, MAX_SPEED, MIN_SPEED, SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
|
/** Path samples are (x, y, theta). Time is sample index / SAMPLE_RATE_HZ. */
|
||||||
|
export function sampleTime(index: number): number {
|
||||||
|
return index / SAMPLE_RATE_HZ
|
||||||
|
}
|
||||||
|
|
||||||
export function trimPathToStartAtHogLine(
|
export function trimPathToStartAtHogLine(
|
||||||
path: [number, number, number][],
|
path: [number, number, number][],
|
||||||
@ -7,9 +12,17 @@ export function trimPathToStartAtHogLine(
|
|||||||
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
if (idx < 0) return path
|
if (idx < 0) return path
|
||||||
// Start just before the hog line crossing so the stone enters smoothly.
|
// Start just before the hog line crossing so the stone enters smoothly.
|
||||||
|
// Theta is preserved; time is rebased via sample index on the trimmed array.
|
||||||
const start = Math.max(0, idx - 1)
|
const start = Math.max(0, idx - 1)
|
||||||
const t0 = path[start][2]
|
return path.slice(start)
|
||||||
return path.slice(start).map(([x, y, t]) => [x, y, t - t0])
|
}
|
||||||
|
|
||||||
|
/** Index at which a path should start for hog-line sync (same as trim start). */
|
||||||
|
export function hogTrimStartIndex(path: [number, number, number][]): number {
|
||||||
|
if (path.length < 2) return 0
|
||||||
|
const idx = path.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||||
|
if (idx < 0) return 0
|
||||||
|
return Math.max(0, idx - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function velocityToWeight(velocity: number): number {
|
export function velocityToWeight(velocity: number): number {
|
||||||
@ -23,4 +36,3 @@ export function weightToVelocity(weight: number): number {
|
|||||||
const t = (clamped - 1) / 9
|
const t = (clamped - 1) / 9
|
||||||
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
return MIN_SPEED + t * (MAX_SPEED - MIN_SPEED)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,51 +1,73 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { GameModel } from './game-model'
|
import { GameModel } from './game-model'
|
||||||
import type { ServerStoneTrajectory, StoneState } from './protocol'
|
import type { StoneId, StonePath, StoneState, Team } from './protocol'
|
||||||
|
import { SAMPLE_RATE_HZ } from './protocol'
|
||||||
|
|
||||||
|
function sid(team: Team, n: number): StoneId {
|
||||||
|
return { team, n }
|
||||||
|
}
|
||||||
|
|
||||||
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
|
||||||
return {
|
return {
|
||||||
x: 0,
|
x: 0,
|
||||||
y: 30,
|
y: 30,
|
||||||
rotation: 0,
|
rotation: 0,
|
||||||
active: true,
|
|
||||||
...partial,
|
...partial,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Build (x,y,theta) samples; time comes from index / SAMPLE_RATE_HZ. */
|
||||||
|
function pathSamples(
|
||||||
|
points: { x: number; y: number; theta?: number }[],
|
||||||
|
): [number, number, number][] {
|
||||||
|
return points.map((p) => [p.x, p.y, p.theta ?? 0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function stonePath(
|
||||||
|
stone_id: StoneId,
|
||||||
|
team: Team,
|
||||||
|
trajectory: [number, number, number][],
|
||||||
|
): StonePath {
|
||||||
|
return {
|
||||||
|
stone_id,
|
||||||
|
rotation: trajectory[trajectory.length - 1]?.[2] ?? 0,
|
||||||
|
team,
|
||||||
|
trajectory,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
describe('GameModel multi-path trajectory animation', () => {
|
describe('GameModel multi-path trajectory animation', () => {
|
||||||
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
it('returns two DrawableStones in parallel mid-trajectory', () => {
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
|
model.state.stones = [
|
||||||
model.state.turnTeam = 'red'
|
stone({ id: sid('team1', 1), team: 'team1' }),
|
||||||
|
stone({ id: sid('team2', 1), team: 'team2' }),
|
||||||
|
]
|
||||||
|
model.state.turnTeam = 'team1'
|
||||||
|
|
||||||
const paths: ServerStoneTrajectory[] = [
|
// 21 samples → duration 20/40 = 0.5s; mid at 0.25s is sample 10 → y=11
|
||||||
{
|
const n = 21
|
||||||
stone_id: 1,
|
const path1 = pathSamples(
|
||||||
path: [
|
Array.from({ length: n }, (_, i) => ({ x: 0, y: 10 + i * 0.1, theta: 0 })),
|
||||||
[0, 10, 0],
|
)
|
||||||
[0, 11, 0.5],
|
const path2 = pathSamples(
|
||||||
[0, 12, 1.0],
|
Array.from({ length: n }, (_, i) => ({ x: 1, y: 10 + i * 0.1, theta: 0 })),
|
||||||
],
|
)
|
||||||
},
|
|
||||||
{
|
const paths: StonePath[] = [
|
||||||
stone_id: 2,
|
stonePath(sid('team1', 1), 'team1', path1),
|
||||||
path: [
|
stonePath(sid('team2', 1), 'team2', path2),
|
||||||
[1, 10, 0],
|
|
||||||
[1, 11, 0.5],
|
|
||||||
[1, 12, 1.0],
|
|
||||||
],
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
model.startTrajectory(paths)
|
model.startTrajectory(paths)
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
const mid = performance.now() + 500
|
const mid = performance.now() + 250
|
||||||
const drawn = model.tick(mid)
|
const drawn = model.tick(mid)
|
||||||
|
|
||||||
expect(drawn).toHaveLength(2)
|
expect(drawn).toHaveLength(2)
|
||||||
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
|
expect(drawn.map((d) => d.team).sort()).toEqual(['team1', 'team2'])
|
||||||
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift)
|
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim)
|
||||||
for (const d of drawn) {
|
for (const d of drawn) {
|
||||||
expect(d.y).toBeCloseTo(11, 0)
|
expect(d.y).toBeCloseTo(11, 0)
|
||||||
}
|
}
|
||||||
@ -53,22 +75,24 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
|
|
||||||
it('clears animating when elapsed reaches maxTotal on a short path', () => {
|
it('clears animating when elapsed reaches maxTotal on a short path', () => {
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
model.state.stones = [stone({ id: 1, team: 'red' })]
|
model.state.stones = [stone({ id: sid('team1', 1), team: 'team1' })]
|
||||||
model.state.turnTeam = 'red'
|
model.state.turnTeam = 'team1'
|
||||||
|
|
||||||
|
// 3 samples → max t = 2/40 = 0.05s
|
||||||
model.startTrajectory([
|
model.startTrajectory([
|
||||||
{
|
stonePath(
|
||||||
stone_id: 1,
|
sid('team1', 1),
|
||||||
path: [
|
'team1',
|
||||||
[0, 10, 0],
|
pathSamples([
|
||||||
[0, 10.5, 0.2],
|
{ x: 0, y: 10 },
|
||||||
[0, 11, 0.4],
|
{ x: 0, y: 10.5 },
|
||||||
],
|
{ x: 0, y: 11 },
|
||||||
},
|
]),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
const afterEnd = performance.now() + 500
|
const afterEnd = performance.now() + 200
|
||||||
const drawn = model.tick(afterEnd)
|
const drawn = model.tick(afterEnd)
|
||||||
|
|
||||||
expect(drawn).toEqual([])
|
expect(drawn).toEqual([])
|
||||||
@ -77,28 +101,53 @@ describe('GameModel multi-path trajectory animation', () => {
|
|||||||
|
|
||||||
it('trims thrown stone path to hog line when id is not in existing stones', () => {
|
it('trims thrown stone path to hog line when id is not in existing stones', () => {
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
// Only stone 1 is already on the sheet; stone 2 is the newly thrown rock.
|
// Only stone team2/1 is already on the sheet; team1/1 is the newly thrown rock.
|
||||||
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
|
model.state.stones = [stone({ id: sid('team2', 1), team: 'team2', x: 0.5, y: 35 })]
|
||||||
model.state.turnTeam = 'red'
|
model.state.turnTeam = 'team1'
|
||||||
|
|
||||||
const thrownPath: [number, number, number][] = [
|
const thrownPath = pathSamples([
|
||||||
[0, 2, 0],
|
{ x: 0, y: 2, theta: 0.1 },
|
||||||
[0, 20, 1],
|
{ x: 0, y: 20, theta: 0.2 },
|
||||||
[0, 21.5, 2],
|
{ x: 0, y: 21.5, theta: 0.3 },
|
||||||
[0, 30, 3],
|
{ x: 0, y: 30, theta: 0.4 },
|
||||||
]
|
])
|
||||||
|
|
||||||
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
|
model.startTrajectory([stonePath(sid('team1', 1), 'team1', thrownPath)])
|
||||||
expect(model.state.animating).toBe(true)
|
expect(model.state.animating).toBe(true)
|
||||||
|
|
||||||
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0
|
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog)
|
||||||
const atStart = performance.now()
|
const atStart = performance.now()
|
||||||
const drawn = model.tick(atStart)
|
const drawn = model.tick(atStart)
|
||||||
|
|
||||||
expect(drawn).toHaveLength(1)
|
expect(drawn).toHaveLength(1)
|
||||||
expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id
|
expect(drawn[0].team).toBe('team1')
|
||||||
expect(drawn[0].y).toBeCloseTo(20, 0)
|
expect(drawn[0].y).toBeCloseTo(20, 0)
|
||||||
// Must not still be at the hack (y=2)
|
// Must not still be at the hack (y=2)
|
||||||
expect(drawn[0].y).toBeGreaterThan(15)
|
expect(drawn[0].y).toBeGreaterThan(15)
|
||||||
|
// Uses body theta from path (small elapsed may blend toward next sample)
|
||||||
|
expect(drawn[0].rotation).toBeCloseTo(0.2, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores scoreboard and stones_remaining from game_state', () => {
|
||||||
|
const model = new GameModel()
|
||||||
|
model.updateGameState({
|
||||||
|
type: 'game_state',
|
||||||
|
end: 2,
|
||||||
|
scores: [1, 0],
|
||||||
|
hammer: 'team2',
|
||||||
|
turn_team: 'team1',
|
||||||
|
scoreboard: [{ end: 1, hammer: 'team1', team1: 1, team2: 0 }],
|
||||||
|
stones_remaining: [7, 8],
|
||||||
|
stones: [],
|
||||||
|
phase: 'playing',
|
||||||
|
})
|
||||||
|
expect(model.state.scoreboard).toHaveLength(1)
|
||||||
|
expect(model.state.scoreboard[0].team1).toBe(1)
|
||||||
|
expect(model.state.stonesRemaining).toEqual([7, 8])
|
||||||
|
expect(model.state.hammer).toBe('team2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses sample index for time (SAMPLE_RATE_HZ)', () => {
|
||||||
|
expect(SAMPLE_RATE_HZ).toBe(40)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,5 +1,17 @@
|
|||||||
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
import {
|
||||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
HOUSE_CENTER,
|
||||||
|
stoneIdKey,
|
||||||
|
stoneIdsEqual,
|
||||||
|
type DrawableStone,
|
||||||
|
type EndScore,
|
||||||
|
type Phase,
|
||||||
|
type ServerGameStateMessage,
|
||||||
|
type StoneId,
|
||||||
|
type StonePath,
|
||||||
|
type StoneState,
|
||||||
|
type Team,
|
||||||
|
} from './protocol'
|
||||||
|
import { hogTrimStartIndex, sampleTime, trimPathToStartAtHogLine } from './game-helpers'
|
||||||
|
|
||||||
export interface GameModelState {
|
export interface GameModelState {
|
||||||
end: number
|
end: number
|
||||||
@ -9,6 +21,8 @@ export interface GameModelState {
|
|||||||
myTeam: Team | null
|
myTeam: Team | null
|
||||||
phase: Phase
|
phase: Phase
|
||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
|
scoreboard: EndScore[]
|
||||||
|
stonesRemaining: number[]
|
||||||
animating: boolean
|
animating: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -16,11 +30,13 @@ export class GameModel {
|
|||||||
state: GameModelState = {
|
state: GameModelState = {
|
||||||
end: 1,
|
end: 1,
|
||||||
scores: [0, 0],
|
scores: [0, 0],
|
||||||
hammer: 'red',
|
hammer: 'team1',
|
||||||
turnTeam: 'red',
|
turnTeam: 'team1',
|
||||||
myTeam: null,
|
myTeam: null,
|
||||||
phase: 'waiting',
|
phase: 'waiting',
|
||||||
stones: [],
|
stones: [],
|
||||||
|
scoreboard: [],
|
||||||
|
stonesRemaining: [8, 8],
|
||||||
animating: false,
|
animating: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -29,7 +45,7 @@ export class GameModel {
|
|||||||
isDragging = false
|
isDragging = false
|
||||||
isPanning = false
|
isPanning = false
|
||||||
|
|
||||||
private activePaths = new Map<number, [number, number, number][]>()
|
private activePaths = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
||||||
private animationStartTime = 0
|
private animationStartTime = 0
|
||||||
|
|
||||||
setMyTeam(team: Team): void {
|
setMyTeam(team: Team): void {
|
||||||
@ -60,49 +76,44 @@ export class GameModel {
|
|||||||
hammer: msg.hammer,
|
hammer: msg.hammer,
|
||||||
turnTeam: msg.turn_team,
|
turnTeam: msg.turn_team,
|
||||||
phase: msg.phase,
|
phase: msg.phase,
|
||||||
|
scoreboard: msg.scoreboard ?? [],
|
||||||
|
stonesRemaining: msg.stones_remaining ?? [8, 8],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startTrajectory(paths: ServerStoneTrajectory[]): void {
|
startTrajectory(stones: StonePath[]): void {
|
||||||
const existingIds = new Set(this.state.stones.map((s) => s.id))
|
const existingKeys = new Set(this.state.stones.map((s) => stoneIdKey(s.id)))
|
||||||
|
|
||||||
let thrownId: number | null = null
|
let thrownId: StoneId | null = null
|
||||||
for (const { stone_id } of paths) {
|
for (const { stone_id } of stones) {
|
||||||
if (!existingIds.has(stone_id)) {
|
if (!existingKeys.has(stoneIdKey(stone_id))) {
|
||||||
thrownId = stone_id
|
thrownId = stone_id
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (thrownId === null && paths.length > 0) {
|
if (thrownId === null && stones.length > 0) {
|
||||||
thrownId = paths[0].stone_id
|
thrownId = stones[0].stone_id
|
||||||
}
|
}
|
||||||
|
|
||||||
let tRef = 0
|
// Shared sample-index trim so multi-stone paths stay on one clock.
|
||||||
|
// Path entries are (x, y, theta); t = index / SAMPLE_RATE_HZ after trim.
|
||||||
|
let startIdx = 0
|
||||||
if (thrownId !== null) {
|
if (thrownId !== null) {
|
||||||
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
const thrownPath = stones.find((p) => stoneIdsEqual(p.stone_id, thrownId!))?.trajectory ?? []
|
||||||
if (thrownPath.length >= 2) {
|
startIdx = hogTrimStartIndex(thrownPath)
|
||||||
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
|
|
||||||
if (idx >= 0) {
|
|
||||||
const start = Math.max(0, idx - 1)
|
|
||||||
tRef = thrownPath[start][2]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pathMap = new Map<number, [number, number, number][]>()
|
const pathMap = new Map<string, { id: StoneId; team: Team; path: [number, number, number][] }>()
|
||||||
for (const { stone_id, path } of paths) {
|
for (const { stone_id, team, trajectory } of stones) {
|
||||||
if (stone_id === thrownId) {
|
const path =
|
||||||
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
thrownId !== null && stoneIdsEqual(stone_id, thrownId)
|
||||||
} else {
|
? trimPathToStartAtHogLine(trajectory)
|
||||||
const shifted = path
|
: trajectory.slice(startIdx)
|
||||||
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
|
pathMap.set(stoneIdKey(stone_id), { id: stone_id, team, path })
|
||||||
.filter(([, , t]) => t >= 0)
|
|
||||||
pathMap.set(stone_id, shifted)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.activePaths = pathMap
|
this.activePaths = pathMap
|
||||||
this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
|
this.state.animating = Array.from(pathMap.values()).some((p) => p.path.length > 1)
|
||||||
this.animationStartTime = performance.now()
|
this.animationStartTime = performance.now()
|
||||||
this.pendingStones = []
|
this.pendingStones = []
|
||||||
}
|
}
|
||||||
@ -115,7 +126,9 @@ export class GameModel {
|
|||||||
const elapsed = (now - this.animationStartTime) / 1000
|
const elapsed = (now - this.animationStartTime) / 1000
|
||||||
const maxTotal = Math.max(
|
const maxTotal = Math.max(
|
||||||
0,
|
0,
|
||||||
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
|
...Array.from(this.activePaths.values()).map((p) =>
|
||||||
|
p.path.length > 0 ? sampleTime(p.path.length - 1) : 0,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if (elapsed >= maxTotal) {
|
if (elapsed >= maxTotal) {
|
||||||
@ -128,11 +141,12 @@ export class GameModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result: DrawableStone[] = []
|
const result: DrawableStone[] = []
|
||||||
for (const [stoneId, path] of this.activePaths) {
|
for (const { id, team, path } of this.activePaths.values()) {
|
||||||
const pos = this.interpolatePath(path, elapsed)
|
const pos = this.interpolatePath(path, elapsed)
|
||||||
if (!pos) continue
|
if (!pos) continue
|
||||||
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam
|
const resolvedTeam =
|
||||||
result.push({ ...pos, team })
|
this.state.stones.find((s) => stoneIdsEqual(s.id, id))?.team ?? team ?? this.state.turnTeam
|
||||||
|
result.push({ ...pos, team: resolvedTeam })
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
@ -143,31 +157,33 @@ export class GameModel {
|
|||||||
): { x: number; y: number; rotation: number } | null {
|
): { x: number; y: number; rotation: number } | null {
|
||||||
if (path.length === 0) return null
|
if (path.length === 0) return null
|
||||||
if (path.length === 1) {
|
if (path.length === 1) {
|
||||||
const [x, y] = path[0]
|
const [x, y, theta] = path[0]
|
||||||
return { x, y, rotation: 0 }
|
return { x, y, rotation: theta }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (elapsed >= path[path.length - 1][2]) {
|
const lastT = sampleTime(path.length - 1)
|
||||||
|
if (elapsed >= lastT) {
|
||||||
const last = path[path.length - 1]
|
const last = path[path.length - 1]
|
||||||
const prev = path[path.length - 2]
|
return { x: last[0], y: last[1], rotation: last[2] }
|
||||||
const dx = last[0] - prev[0]
|
|
||||||
const dy = last[1] - prev[1]
|
|
||||||
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find segment where sampleTime(i) <= elapsed < sampleTime(i+1)
|
||||||
let i = 0
|
let i = 0
|
||||||
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
|
while (i + 1 < path.length && sampleTime(i + 1) < elapsed) i++
|
||||||
const p0 = path[i]
|
const p0 = path[i]
|
||||||
const p1 = path[i + 1] ?? p0
|
const p1 = path[i + 1] ?? p0
|
||||||
const t0 = path[Math.max(i - 1, 0)]
|
const t0 = sampleTime(i)
|
||||||
const t2 = path[Math.min(i + 2, path.length - 1)]
|
const t1 = sampleTime(i + 1)
|
||||||
const dt = p1[2] - p0[2]
|
const dt = t1 - t0
|
||||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
const t = dt > 0 ? (elapsed - t0) / dt : 0
|
||||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||||
const dx = t2[0] - t0[0]
|
// Interpolate body rotation (theta) from path samples
|
||||||
const dy = t2[1] - t0[1]
|
let dTheta = p1[2] - p0[2]
|
||||||
const rotation = Math.atan2(dy, dx) * 2
|
// Unwrap shortest path across ±π
|
||||||
|
if (dTheta > Math.PI) dTheta -= 2 * Math.PI
|
||||||
|
if (dTheta < -Math.PI) dTheta += 2 * Math.PI
|
||||||
|
const rotation = p0[2] + dTheta * t
|
||||||
return { x, y, rotation }
|
return { x, y, rotation }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -36,8 +36,10 @@ export function startGame(): void {
|
|||||||
hud.setShareLink(shareLink)
|
hud.setShareLink(shareLink)
|
||||||
|
|
||||||
const model = new GameModel()
|
const model = new GameModel()
|
||||||
|
let lastScoreboardLen = model.state.scoreboard.length
|
||||||
|
|
||||||
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
|
const stored = localStorage.getItem('curltastic-team')
|
||||||
|
const initialTeam: Team = stored === 'team2' || stored === 'yellow' ? 'team2' : 'team1'
|
||||||
model.setMyTeam(initialTeam)
|
model.setMyTeam(initialTeam)
|
||||||
hud.setTeam(initialTeam)
|
hud.setTeam(initialTeam)
|
||||||
|
|
||||||
@ -54,6 +56,24 @@ export function startGame(): void {
|
|||||||
hud.update(model.state)
|
hud.update(model.state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const maybeShowEndModal = () => {
|
||||||
|
const board = model.state.scoreboard
|
||||||
|
if (board.length <= lastScoreboardLen) {
|
||||||
|
lastScoreboardLen = board.length
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const last = board[board.length - 1]
|
||||||
|
lastScoreboardLen = board.length
|
||||||
|
if (!last) return
|
||||||
|
hud.showEndModal({
|
||||||
|
end: last.end,
|
||||||
|
team1: last.team1,
|
||||||
|
team2: last.team2,
|
||||||
|
nextHammer: model.state.hammer,
|
||||||
|
scoreboard: board,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
hud.teamSelect.addEventListener('change', () => {
|
hud.teamSelect.addEventListener('change', () => {
|
||||||
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
||||||
updateControls()
|
updateControls()
|
||||||
@ -90,19 +110,13 @@ export function startGame(): void {
|
|||||||
},
|
},
|
||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
model.updateGameState(msg)
|
model.updateGameState(msg)
|
||||||
|
maybeShowEndModal()
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectory: (paths) => {
|
onTrajectories: (stones) => {
|
||||||
model.startTrajectory(paths)
|
model.startTrajectory(stones)
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onEndScored: (end, points, scoringTeam) => {
|
|
||||||
if (points > 0 && scoringTeam) {
|
|
||||||
hud.showToast(`${scoringTeam.toUpperCase()} scores ${points} in end ${end}`)
|
|
||||||
} else {
|
|
||||||
hud.showToast(`End ${end} scored: blank end`)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onGameOver: (scores, winner) => {
|
onGameOver: (scores, winner) => {
|
||||||
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
|
const msg = winner ? `${winner.toUpperCase()} wins!` : 'Tie game!'
|
||||||
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
|
hud.showToast(`Game over: ${msg} (${scores[0]}-${scores[1]})`)
|
||||||
@ -178,7 +192,7 @@ export function startGame(): void {
|
|||||||
hud.teamSelect.value as Team,
|
hud.teamSelect.value as Team,
|
||||||
model.broom.x,
|
model.broom.x,
|
||||||
model.broom.y,
|
model.broom.y,
|
||||||
velocity.getWeight(),
|
velocity.getVelocity(),
|
||||||
curls.getSelected(),
|
curls.getSelected(),
|
||||||
friction.getFriction(),
|
friction.getFriction(),
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { MAX_SPEED, MIN_SPEED, type Phase, type Team } from './protocol'
|
import {
|
||||||
|
MAX_SPEED,
|
||||||
|
MIN_SPEED,
|
||||||
|
STONES_PER_TEAM,
|
||||||
|
type EndScore,
|
||||||
|
type Phase,
|
||||||
|
type Team,
|
||||||
|
} from './protocol'
|
||||||
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
import { velocityToWeight, weightToVelocity } from './game-helpers'
|
||||||
|
|
||||||
export interface Hud {
|
export interface Hud {
|
||||||
@ -16,6 +23,15 @@ export interface Hud {
|
|||||||
hammer: Team
|
hammer: Team
|
||||||
turnTeam: Team
|
turnTeam: Team
|
||||||
animating: boolean
|
animating: boolean
|
||||||
|
stonesRemaining: number[]
|
||||||
|
scoreboard: EndScore[]
|
||||||
|
}) => void
|
||||||
|
showEndModal: (payload: {
|
||||||
|
end: number
|
||||||
|
team1: number
|
||||||
|
team2: number
|
||||||
|
nextHammer: Team
|
||||||
|
scoreboard: EndScore[]
|
||||||
}) => void
|
}) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
setShareLink: (link: string) => void
|
setShareLink: (link: string) => void
|
||||||
@ -45,34 +61,67 @@ function copyText(text: string): Promise<void> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TEAM_LABELS: Record<Team, string> = {
|
||||||
|
team1: 'Team 1',
|
||||||
|
team2: 'Team 2',
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStoneChipsHtml(team: Team): string {
|
||||||
|
const chips = Array.from({ length: STONES_PER_TEAM }, (_, i) => {
|
||||||
|
return `<span class="stone-chip stone-chip--${team}" data-index="${i}" aria-hidden="true"></span>`
|
||||||
|
}).join('')
|
||||||
|
// Hammer glyph lives on the row of the team that has last-rock; toggled in update().
|
||||||
|
return `<div class="stones-row stones-row--${team}" data-team="${team}" role="img" aria-label="${TEAM_LABELS[team]} stones remaining"><span class="hammer-badge" title="Hammer" aria-hidden="true">🔨</span>${chips}</div>`
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderScoreboardTable(scoreboard: EndScore[]): string {
|
||||||
|
if (scoreboard.length === 0) {
|
||||||
|
return '<p class="end-modal-empty">No ends scored yet</p>'
|
||||||
|
}
|
||||||
|
const rows = scoreboard
|
||||||
|
.map(
|
||||||
|
(e) =>
|
||||||
|
`<tr><td>${e.end}</td><td>${e.team1}</td><td>${e.team2}</td><td>${TEAM_LABELS[e.hammer]}</td></tr>`,
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
return `<table class="scoreboard-table" aria-label="Scoreboard">
|
||||||
|
<thead><tr><th>End</th><th>Team 1</th><th>Team 2</th><th>Hammer</th></tr></thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`
|
||||||
|
}
|
||||||
|
|
||||||
export function createHud(): Hud {
|
export function createHud(): Hud {
|
||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
root.id = 'hud'
|
root.id = 'hud'
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
<div id="hud-top-group">
|
<div id="hud-top-group">
|
||||||
<div class="hud-row" id="share-row">
|
<div class="hud-row" id="share-row">
|
||||||
<div id="share"><button>Copy share link</button></div>
|
<div id="share"><button type="button">Copy share link</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="stones-hud" aria-live="polite">
|
||||||
|
${buildStoneChipsHtml('team1')}
|
||||||
|
${buildStoneChipsHtml('team2')}
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-row">
|
<div class="hud-row">
|
||||||
<div id="score">Red 0 - Yellow 0</div>
|
<div id="score">Team 1 0 - Team 2 0</div>
|
||||||
<div id="end-info">End 1 · Waiting</div>
|
<div id="end-info">End 1 · Waiting</div>
|
||||||
<select id="team-select" aria-label="Team">
|
<select id="team-select" aria-label="Team">
|
||||||
<option value="red">Red</option>
|
<option value="team1">Team 1</option>
|
||||||
<option value="yellow">Yellow</option>
|
<option value="team2">Team 2</option>
|
||||||
</select>
|
</select>
|
||||||
<div id="hammer">Hammer: -</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div id="scoreboard-strip" class="scoreboard-strip" aria-label="End scores"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="hud-row" style="align-items:flex-end;">
|
<div class="hud-row" style="align-items:flex-end;">
|
||||||
<div id="velocity-control"></div>
|
<div id="velocity-control"></div>
|
||||||
<div id="curl-selector"></div>
|
<div id="curl-selector"></div>
|
||||||
<div id="friction-control">
|
<div id="friction-control">
|
||||||
<label for="friction-slider">Friction</label>
|
<label for="friction-slider">Friction</label>
|
||||||
<input id="friction-slider" type="range" min="0.5" max="2.0" step="0.1" value="1.0" />
|
<input id="friction-slider" type="range" min="0.5" max="1.5" step="0.1" value="1.0" />
|
||||||
<span id="friction-value">1.0</span>
|
<span id="friction-value">1.0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button id="throw-btn" disabled>THROW</button>
|
<button id="throw-btn" type="button" disabled>THROW</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="waiting">Waiting</div>
|
<div id="waiting">Waiting</div>
|
||||||
@ -81,8 +130,79 @@ export function createHud(): Hud {
|
|||||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
|
const stonesHud = root.querySelector<HTMLDivElement>('#stones-hud')!
|
||||||
|
const scoreboardStrip = root.querySelector<HTMLDivElement>('#scoreboard-strip')!
|
||||||
|
|
||||||
|
const updateHammerBadge = (hammer: Team) => {
|
||||||
|
for (const team of ['team1', 'team2'] as const) {
|
||||||
|
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
|
||||||
|
const badge = row?.querySelector<HTMLSpanElement>('.hammer-badge')
|
||||||
|
if (!badge || !row) continue
|
||||||
|
const hasHammer = team === hammer
|
||||||
|
// Class only — keep the 18px hammer column on both rows so chips align.
|
||||||
|
row.classList.toggle('stones-row--hammer', hasHammer)
|
||||||
|
if (hasHammer) {
|
||||||
|
badge.removeAttribute('aria-hidden')
|
||||||
|
badge.setAttribute('aria-label', 'Hammer')
|
||||||
|
} else {
|
||||||
|
badge.setAttribute('aria-hidden', 'true')
|
||||||
|
badge.removeAttribute('aria-label')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let endModalEl: HTMLDivElement | null = null
|
||||||
|
let endModalTimer = 0
|
||||||
|
|
||||||
|
const dismissEndModal = () => {
|
||||||
|
if (endModalTimer) {
|
||||||
|
window.clearTimeout(endModalTimer)
|
||||||
|
endModalTimer = 0
|
||||||
|
}
|
||||||
|
if (endModalEl) {
|
||||||
|
endModalEl.remove()
|
||||||
|
endModalEl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateStonesRemaining = (remaining: number[]) => {
|
||||||
|
const teams: Team[] = ['team1', 'team2']
|
||||||
|
for (let t = 0; t < teams.length; t++) {
|
||||||
|
const team = teams[t]
|
||||||
|
const left = Math.max(0, Math.min(STONES_PER_TEAM, remaining[t] ?? STONES_PER_TEAM))
|
||||||
|
// Thrown stones remove leftmost chips: chip i is remaining when i >= (8 - left).
|
||||||
|
const firstRemaining = STONES_PER_TEAM - left
|
||||||
|
const row = stonesHud.querySelector<HTMLDivElement>(`.stones-row--${team}`)
|
||||||
|
if (!row) continue
|
||||||
|
const hammer = row.classList.contains('stones-row--hammer')
|
||||||
|
row.setAttribute(
|
||||||
|
'aria-label',
|
||||||
|
`${TEAM_LABELS[team]} ${left} stones remaining${hammer ? ' (hammer)' : ''}`,
|
||||||
|
)
|
||||||
|
row.querySelectorAll<HTMLSpanElement>('.stone-chip').forEach((chip) => {
|
||||||
|
const index = Number(chip.dataset.index)
|
||||||
|
const isRemaining = index >= firstRemaining
|
||||||
|
chip.classList.toggle('stone-chip--gone', !isRemaining)
|
||||||
|
chip.classList.toggle('stone-chip--remaining', isRemaining)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateScoreboardStrip = (scoreboard: EndScore[], totals: number[]) => {
|
||||||
|
if (scoreboard.length === 0) {
|
||||||
|
scoreboardStrip.textContent = ''
|
||||||
|
scoreboardStrip.hidden = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scoreboardStrip.hidden = false
|
||||||
|
const cells = scoreboard
|
||||||
|
.map((e) => `<span class="scoreboard-end" title="End ${e.end}">${e.team1}-${e.team2}</span>`)
|
||||||
|
.join('')
|
||||||
|
scoreboardStrip.innerHTML = `${cells}<span class="scoreboard-total">Σ ${totals[0] ?? 0}-${totals[1] ?? 0}</span>`
|
||||||
|
}
|
||||||
|
|
||||||
|
updateStonesRemaining([STONES_PER_TEAM, STONES_PER_TEAM])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
@ -95,12 +215,46 @@ export function createHud(): Hud {
|
|||||||
teamSelect.value = team
|
teamSelect.value = team
|
||||||
},
|
},
|
||||||
update: (state) => {
|
update: (state) => {
|
||||||
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
scoreEl.textContent = `Team 1 ${state.scores[0] ?? 0} - Team 2 ${state.scores[1] ?? 0}`
|
||||||
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
const phaseText =
|
||||||
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
state.phase === 'playing'
|
||||||
|
? `${TEAM_LABELS[state.turnTeam]}'s turn`
|
||||||
|
: state.phase.replace(/_/g, ' ')
|
||||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
|
||||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
|
// Hammer class first so stones-remaining aria can mention it.
|
||||||
|
updateHammerBadge(state.hammer)
|
||||||
|
updateStonesRemaining(state.stonesRemaining)
|
||||||
|
updateScoreboardStrip(state.scoreboard, state.scores)
|
||||||
|
},
|
||||||
|
showEndModal: (payload) => {
|
||||||
|
dismissEndModal()
|
||||||
|
const modal = document.createElement('div')
|
||||||
|
modal.id = 'end-modal'
|
||||||
|
modal.setAttribute('role', 'dialog')
|
||||||
|
modal.setAttribute('aria-modal', 'true')
|
||||||
|
modal.setAttribute('aria-labelledby', 'end-modal-title')
|
||||||
|
modal.innerHTML = `
|
||||||
|
<div class="end-modal-backdrop" data-dismiss="1"></div>
|
||||||
|
<div class="end-modal-card">
|
||||||
|
<h2 id="end-modal-title">End ${payload.end} complete</h2>
|
||||||
|
<p class="end-modal-points">
|
||||||
|
<span class="end-modal-team end-modal-team--team1">Team 1 <strong>${payload.team1}</strong></span>
|
||||||
|
<span class="end-modal-vs">vs</span>
|
||||||
|
<span class="end-modal-team end-modal-team--team2">Team 2 <strong>${payload.team2}</strong></span>
|
||||||
|
</p>
|
||||||
|
<p class="end-modal-hammer">Next hammer: <strong>${TEAM_LABELS[payload.nextHammer]}</strong></p>
|
||||||
|
<div class="end-modal-board">${renderScoreboardTable(payload.scoreboard)}</div>
|
||||||
|
<button type="button" class="end-modal-dismiss" data-dismiss="1">Dismiss</button>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (t.closest('[data-dismiss]')) dismissEndModal()
|
||||||
|
})
|
||||||
|
document.body.appendChild(modal)
|
||||||
|
endModalEl = modal
|
||||||
|
endModalTimer = window.setTimeout(dismissEndModal, 5000)
|
||||||
},
|
},
|
||||||
showToast: (message: string) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
@ -126,7 +280,7 @@ export function createHud(): Hud {
|
|||||||
export function createVelocitySelector(
|
export function createVelocitySelector(
|
||||||
container: HTMLDivElement,
|
container: HTMLDivElement,
|
||||||
onSelect: () => void,
|
onSelect: () => void,
|
||||||
): { getWeight: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getVelocity: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
const state = { weight: 5 }
|
const state = { weight: 5 }
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
|
|
||||||
@ -173,7 +327,7 @@ export function createVelocitySelector(
|
|||||||
container.appendChild(wrap)
|
container.appendChild(wrap)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
getWeight: () => state.weight,
|
getVelocity: () => Number(slider.value),
|
||||||
setEnabled: (enabled) => {
|
setEnabled: (enabled) => {
|
||||||
slider.disabled = !enabled
|
slider.disabled = !enabled
|
||||||
},
|
},
|
||||||
@ -184,15 +338,18 @@ export function createCurlSelector(
|
|||||||
container: HTMLDivElement,
|
container: HTMLDivElement,
|
||||||
onSelect: (curl: number) => void,
|
onSelect: (curl: number) => void,
|
||||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
|
// Only full curl: backend curl>0 = clockwise (right), curl<0 = counter-clockwise (left).
|
||||||
|
// Layout L→R: CCW on the left, CW on the right. Default clockwise.
|
||||||
const state = { selected: 1, enabled: true }
|
const state = { selected: 1, enabled: true }
|
||||||
const options = [
|
const options = [
|
||||||
{ value: -1, label: '↷', ariaLabel: 'Left curl' },
|
{ value: -1, label: '↺', ariaLabel: 'Counterclockwise curl' },
|
||||||
{ value: 1, label: '↶', ariaLabel: 'Right curl' },
|
{ value: 1, label: '↻', ariaLabel: 'Clockwise curl' },
|
||||||
]
|
]
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const btn = document.createElement('button')
|
const btn = document.createElement('button')
|
||||||
btn.className = 'curl-btn'
|
btn.className = 'curl-btn'
|
||||||
|
btn.type = 'button'
|
||||||
btn.textContent = opt.label
|
btn.textContent = opt.label
|
||||||
btn.ariaLabel = opt.ariaLabel
|
btn.ariaLabel = opt.ariaLabel
|
||||||
btn.dataset.curl = String(opt.value)
|
btn.dataset.curl = String(opt.value)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ServerGameStateMessage,
|
ServerGameStateMessage,
|
||||||
ServerStoneTrajectory,
|
StonePath,
|
||||||
ServerMessageTyped as ServerMessage,
|
ServerMessageTyped as ServerMessage,
|
||||||
Team,
|
Team,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
@ -17,8 +17,7 @@ export interface NetCallbacks {
|
|||||||
onJoined: (room: string) => void
|
onJoined: (room: string) => void
|
||||||
onWaiting: (message: string) => void
|
onWaiting: (message: string) => void
|
||||||
onGameState: (msg: ServerGameStateMessage) => void
|
onGameState: (msg: ServerGameStateMessage) => void
|
||||||
onTrajectory: (paths: ServerStoneTrajectory[]) => void
|
onTrajectories: (stones: StonePath[]) => void
|
||||||
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
|
||||||
onGameOver: (scores: number[], winner: Team | null) => void
|
onGameOver: (scores: number[], winner: Team | null) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
@ -50,11 +49,8 @@ export function connect(room: string, callbacks: NetCallbacks): void {
|
|||||||
case 'game_state':
|
case 'game_state':
|
||||||
callbacks.onGameState(msg)
|
callbacks.onGameState(msg)
|
||||||
break
|
break
|
||||||
case 'trajectory':
|
case 'trajectories':
|
||||||
callbacks.onTrajectory(msg.paths)
|
callbacks.onTrajectories(msg.stones)
|
||||||
break
|
|
||||||
case 'end_scored':
|
|
||||||
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
|
||||||
break
|
break
|
||||||
case 'game_over':
|
case 'game_over':
|
||||||
callbacks.onGameOver(msg.scores, msg.winner)
|
callbacks.onGameOver(msg.scores, msg.winner)
|
||||||
@ -80,7 +76,7 @@ export function sendThrow(
|
|||||||
team: Team,
|
team: Team,
|
||||||
broomX: number,
|
broomX: number,
|
||||||
broomY: number,
|
broomY: number,
|
||||||
weight: number,
|
velocity: number,
|
||||||
curl: number,
|
curl: number,
|
||||||
friction: number,
|
friction: number,
|
||||||
): void {
|
): void {
|
||||||
@ -91,7 +87,7 @@ export function sendThrow(
|
|||||||
team,
|
team,
|
||||||
broom_x: broomX,
|
broom_x: broomX,
|
||||||
broom_y: broomY,
|
broom_y: broomY,
|
||||||
weight,
|
velocity,
|
||||||
curl,
|
curl,
|
||||||
friction,
|
friction,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@ -15,16 +15,25 @@ export const HOG_LINE_Y = 21.0
|
|||||||
export const BACK_LINE_Y = 42.0
|
export const BACK_LINE_Y = 42.0
|
||||||
export const HACK_Y = 2.0
|
export const HACK_Y = 2.0
|
||||||
export const STONE_RADIUS = 0.15
|
export const STONE_RADIUS = 0.15
|
||||||
export const MIN_SPEED = 3.0
|
/** Soft guard (weight 1). Mid slider (weight 5) ≈ DRAW 2.38 m/s lands near tee. */
|
||||||
export const MAX_SPEED = 6.45
|
export const MIN_SPEED = 1.9
|
||||||
|
/** Heavy (weight 10). Span keeps weight 5 ≈ DRAW_VELOCITY. */
|
||||||
|
export const MAX_SPEED = 3.0
|
||||||
|
|
||||||
|
export type Team = 'team1' | 'team2'
|
||||||
|
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
||||||
|
|
||||||
|
export interface StoneId {
|
||||||
|
team: Team
|
||||||
|
n: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface StoneState {
|
export interface StoneState {
|
||||||
id: number
|
id: StoneId
|
||||||
team: Team
|
team: Team
|
||||||
x: number
|
x: number
|
||||||
y: number
|
y: number
|
||||||
rotation: number
|
rotation: number
|
||||||
active: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DrawableStone {
|
export interface DrawableStone {
|
||||||
@ -34,12 +43,19 @@ export interface DrawableStone {
|
|||||||
team: Team
|
team: Team
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface EndScore {
|
||||||
|
end: number
|
||||||
|
hammer: Team
|
||||||
|
team1: number
|
||||||
|
team2: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientThrowMessage {
|
export interface ClientThrowMessage {
|
||||||
type: 'throw'
|
type: 'throw'
|
||||||
team: Team
|
team: Team
|
||||||
broom_x: number
|
broom_x: number
|
||||||
broom_y: number
|
broom_y: number
|
||||||
weight: number
|
velocity: number
|
||||||
curl: number
|
curl: number
|
||||||
friction: number
|
friction: number
|
||||||
}
|
}
|
||||||
@ -60,25 +76,23 @@ export interface ServerGameStateMessage {
|
|||||||
scores: number[]
|
scores: number[]
|
||||||
hammer: Team
|
hammer: Team
|
||||||
turn_team: Team
|
turn_team: Team
|
||||||
|
scoreboard: EndScore[]
|
||||||
|
stones_remaining: number[]
|
||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
phase: Phase
|
phase: Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerStoneTrajectory {
|
/** Path samples are (x, y, theta). Time is sample_index / SAMPLE_RATE_HZ. */
|
||||||
stone_id: number
|
export interface StonePath {
|
||||||
path: [number, number, number][]
|
stone_id: StoneId
|
||||||
|
rotation: number
|
||||||
|
team: Team
|
||||||
|
trajectory: [number, number, number][]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerTrajectoryMessage {
|
export interface ServerTrajectoriesMessage {
|
||||||
type: 'trajectory'
|
type: 'trajectories'
|
||||||
paths: ServerStoneTrajectory[]
|
stones: StonePath[]
|
||||||
}
|
|
||||||
|
|
||||||
export interface ServerEndScoredMessage {
|
|
||||||
type: 'end_scored'
|
|
||||||
end: number
|
|
||||||
points: number
|
|
||||||
scoring_team?: Team
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerGameOverMessage {
|
export interface ServerGameOverMessage {
|
||||||
@ -96,15 +110,20 @@ export type ServerMessageTyped =
|
|||||||
| ServerJoinedMessage
|
| ServerJoinedMessage
|
||||||
| ServerWaitingMessage
|
| ServerWaitingMessage
|
||||||
| ServerGameStateMessage
|
| ServerGameStateMessage
|
||||||
| ServerTrajectoryMessage
|
| ServerTrajectoriesMessage
|
||||||
| ServerEndScoredMessage
|
|
||||||
| ServerGameOverMessage
|
| ServerGameOverMessage
|
||||||
| ServerErrorMessage
|
| ServerErrorMessage
|
||||||
|
|
||||||
export type Team = 'red' | 'yellow'
|
|
||||||
export type Phase = 'waiting' | 'playing' | 'simulating' | 'scoring' | 'end_complete' | 'game_complete'
|
|
||||||
export type ServerMessage = ServerMessageTyped
|
export type ServerMessage = ServerMessageTyped
|
||||||
|
|
||||||
export function isTeam(value: unknown): value is Team {
|
export function isTeam(value: unknown): value is Team {
|
||||||
return value === 'red' || value === 'yellow'
|
return value === 'team1' || value === 'team2'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stoneIdKey(id: StoneId): string {
|
||||||
|
return `${id.team}:${id.n}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stoneIdsEqual(a: StoneId, b: StoneId): boolean {
|
||||||
|
return a.team === b.team && a.n === b.n
|
||||||
}
|
}
|
||||||
|
|||||||
@ -159,23 +159,43 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
const drawStone = (stone: DrawableStone) => {
|
const drawStone = (stone: DrawableStone) => {
|
||||||
const c = worldToScreen(stone.x, stone.y)
|
const c = worldToScreen(stone.x, stone.y)
|
||||||
const r = STONE_RADIUS * scale()
|
const r = STONE_RADIUS * scale()
|
||||||
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
// team1 = red palette, team2 = yellow palette
|
||||||
ctx.beginPath()
|
const rim = stone.team === 'team1' ? '#8b1a12' : '#a66d00'
|
||||||
ctx.arc(c.x, c.y, r, 0, Math.PI * 2)
|
const color = stone.team === 'team1' ? '#d93025' : '#f9ab00'
|
||||||
ctx.fillStyle = color
|
const highlight = stone.team === 'team1' ? '#ff6b5c' : '#ffd666'
|
||||||
ctx.fill()
|
|
||||||
ctx.strokeStyle = '#fff'
|
// Paint but body fully in stone frame so θ from physics is obvious while spinning.
|
||||||
ctx.lineWidth = Math.max(1, scale() * 0.02)
|
// Canvas +Y is down; negate so CCW body angle matches ice coordinates.
|
||||||
ctx.stroke()
|
|
||||||
ctx.save()
|
ctx.save()
|
||||||
ctx.translate(c.x, c.y)
|
ctx.translate(c.x, c.y)
|
||||||
ctx.rotate(-stone.rotation)
|
ctx.rotate(-stone.rotation)
|
||||||
|
|
||||||
|
const bodyGrad = ctx.createRadialGradient(-r * 0.3, -r * 0.35, r * 0.1, 0, 0, r)
|
||||||
|
bodyGrad.addColorStop(0, highlight)
|
||||||
|
bodyGrad.addColorStop(0.55, color)
|
||||||
|
bodyGrad.addColorStop(1, rim)
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(0, 0, r, 0, Math.PI * 2)
|
||||||
|
ctx.fillStyle = bodyGrad
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(255,255,255,0.9)'
|
||||||
|
ctx.lineWidth = Math.max(1.5, scale() * 0.02)
|
||||||
|
ctx.stroke()
|
||||||
|
|
||||||
|
// Asymmetric handle: bright bar + dark toe so spin reads clearly.
|
||||||
|
ctx.fillStyle = 'rgba(255,255,255,0.92)'
|
||||||
|
ctx.fillRect(-r * 0.12, -r * 0.18, r * 0.9, r * 0.36)
|
||||||
|
ctx.fillStyle = 'rgba(20,20,20,0.55)'
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(-r * 0.4, 0, r * 0.22, 0, Math.PI * 2)
|
||||||
|
ctx.fill()
|
||||||
|
ctx.strokeStyle = 'rgba(0,0,0,0.35)'
|
||||||
|
ctx.lineWidth = Math.max(1, scale() * 0.015)
|
||||||
ctx.beginPath()
|
ctx.beginPath()
|
||||||
ctx.moveTo(0, 0)
|
ctx.moveTo(0, 0)
|
||||||
ctx.lineTo(r * 0.8, 0)
|
ctx.lineTo(r * 0.72, 0)
|
||||||
ctx.strokeStyle = 'rgba(0,0,0,0.5)'
|
|
||||||
ctx.lineWidth = Math.max(1, scale() * 0.03)
|
|
||||||
ctx.stroke()
|
ctx.stroke()
|
||||||
|
|
||||||
ctx.restore()
|
ctx.restore()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -90,6 +90,230 @@ html, body {
|
|||||||
gap: 4px;
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— Stones remaining (2×8 skeuomorphic chips) —— */
|
||||||
|
#stones-hud {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
margin: 0 auto 2px;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 14px;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08), 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stones-row {
|
||||||
|
display: grid;
|
||||||
|
/* Fixed hammer column so both team chips align vertically under each other. */
|
||||||
|
grid-template-columns: 18px repeat(8, 18px);
|
||||||
|
column-gap: 5px;
|
||||||
|
align-items: center;
|
||||||
|
justify-items: center;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hammer-badge {
|
||||||
|
grid-column: 1;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.55));
|
||||||
|
/* Keep layout space when the other team has hammer (no collapse / shift). */
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stones-row--hammer .hammer-badge {
|
||||||
|
visibility: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stone-chip {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid rgba(255, 255, 255, 0.85);
|
||||||
|
box-shadow:
|
||||||
|
inset 0 2px 3px rgba(255, 255, 255, 0.45),
|
||||||
|
inset 0 -2px 3px rgba(0, 0, 0, 0.35),
|
||||||
|
0 1px 2px rgba(0, 0, 0, 0.4);
|
||||||
|
transition: opacity 0.2s ease, transform 0.2s ease, filter 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stone-chip--team1 {
|
||||||
|
background: radial-gradient(circle at 35% 30%, #ff6b5c 0%, #d93025 55%, #8b1a12 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stone-chip--team2 {
|
||||||
|
background: radial-gradient(circle at 35% 30%, #ffd666 0%, #f9ab00 55%, #a66d00 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stone-chip--gone {
|
||||||
|
opacity: 0.18;
|
||||||
|
transform: scale(0.72);
|
||||||
|
filter: grayscale(0.6);
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: rgba(255, 255, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-strip[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-end {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-total {
|
||||||
|
background: rgba(0, 170, 102, 0.25);
|
||||||
|
border: 1px solid rgba(0, 170, 102, 0.45);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* —— End-of-end modal —— */
|
||||||
|
#end-modal {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 50;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(5, 14, 28, 0.72);
|
||||||
|
backdrop-filter: blur(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: min(360px, calc(100vw - 32px));
|
||||||
|
max-height: min(80vh, 520px);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 20px 18px 16px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: linear-gradient(160deg, rgba(28, 74, 122, 0.96), rgba(11, 31, 58, 0.98));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.22);
|
||||||
|
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.45), inset 0 1px 0 rgba(255, 255, 255, 0.12);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card h2 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-points {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team strong {
|
||||||
|
font-size: 22px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team--team1 strong {
|
||||||
|
color: #ff6b5c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-team--team2 strong {
|
||||||
|
color: #ffd666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-vs {
|
||||||
|
opacity: 0.55;
|
||||||
|
font-size: 12px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-hammer {
|
||||||
|
margin: 0 0 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-board {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table th,
|
||||||
|
.scoreboard-table td {
|
||||||
|
padding: 6px 4px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scoreboard-table th {
|
||||||
|
font-weight: 700;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
font-size: 10px;
|
||||||
|
opacity: 0.75;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.35);
|
||||||
|
background: rgba(255, 255, 255, 0.14);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 8px 18px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.22);
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-dismiss:focus-visible {
|
||||||
|
outline: 2px solid #00aaff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
#share {
|
#share {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -181,14 +405,15 @@ html, body {
|
|||||||
|
|
||||||
.curl-btn {
|
.curl-btn {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 64px;
|
min-width: 52px;
|
||||||
height: 36px;
|
height: 40px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
border: 2px solid rgba(255, 255, 255, 0.4);
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 13px;
|
font-size: 22px;
|
||||||
|
line-height: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@ -259,6 +484,28 @@ html, body {
|
|||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.stone-chip {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stones-row {
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#stones-hud {
|
||||||
|
padding: 4px 8px;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card {
|
||||||
|
padding: 16px 14px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.end-modal-card h2 {
|
||||||
|
font-size: 17px;
|
||||||
|
}
|
||||||
|
|
||||||
#velocity-control {
|
#velocity-control {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user