Compare commits

..

22 Commits

Author SHA1 Message Date
d631af71dc Merge pull request 'jasonlooked' (#1) from jasonlooked into main
Reviewed-on: http://localhost:3001/eros/curltastic/pulls/1
2026-07-11 10:13:08 -07:00
Jason Dekarske
473e18a751 docs: multi-client free team, free broom, multi-path e2e list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:28:58 -07:00
Jason Dekarske
1239394578 test(e2e): multi-client observers replace room-full check 2026-07-10 23:21:55 -07:00
Jason Dekarske
1e720c22cb refactor: express foot-derived constants via FEET_TO_METERS
House radius (12ft diameter -> 6ft radius), button (6in -> 0.5ft), four-ft, eight-ft, twelve-ft radii now computed at compile time from FEET_TO_METERS = 0.3048.

Ultraworked + Co-authored-by
2026-07-10 23:19:45 -07:00
Jason Dekarske
2eb5760922 fix(frontend): camera shows sidelines and house above controls
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:19:02 -07:00
Jason Dekarske
20c592d8ac fix(frontend): lock multi-stone parallel trajectory animation
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:17:21 -07:00
Jason Dekarske
2e11f005dc fix: allow broom aim anywhere, not only house
Ultraworked

Co-authored-by: Sisyphus <sisyphus@ohmyopenagent.com>
2026-07-10 23:17:15 -07:00
Jason Dekarske
dc62c4f55f docs: multi-client free team pick and shared watchers
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:07 -07:00
Jason Dekarske
b06a78227f test(e2e): multi-client throws and collision multi-path QA
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:07 -07:00
Jason Dekarske
c37026b0a0 feat(frontend): house-centered camera with y-pan offset
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:07 -07:00
Jason Dekarske
56a7d5abba feat(frontend): free team switch and sheet pan for multi-client play
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
c28bf32eb8 feat(frontend): animate all stone trajectories on one clock
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
722e6090d1 refactor(frontend): multi-path trajectory protocol and team on throw
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
a4e848c849 refactor(backend): allow multi-client rooms and drop room-full
Anyone may join and watch/throw; game auto-starts on first connection.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
0e08e21c9b refactor(backend): team-based throws without player slots
Anyone identifying as the turn team can throw; room slots for 1v1 removed.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
5eeaf8929d feat(physics): record trajectories for every moving stone
Collision playback needs all stones sampled on a shared t=0 release clock.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
Jason Dekarske
2f68325801 refactor(backend): multi-stone StoneTrajectory on protocol wire
Clients send team on throw; joined no longer assigns a fixed slot team.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
eros
0ef0dc3d78 refactor(backend): remove remaining dead code and unused constants
Drop unused Game methods, Room.id field, and backend-only sheet ring constants that duplicated frontend definitions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:58:35 -07:00
eros
0ca0f0e3ff refactor(frontend): centralize game state in GameModel
Extract mutable state from game.ts into a dedicated GameModel class to clarify data flow and state transitions.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:55:45 -07:00
eros
ef5349dfe3 refactor(frontend): introduce DrawableStone interface and use in renderer
Replace the ad-hoc StoneState | {...} union in drawStone/draw with a named DrawableStone type.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:51:37 -07:00
eros
23a0cafe18 refactor(backend): split handle_socket and make rooms map async-safe
Replace std::sync::Mutex with tokio::sync::Mutex; extract try_join_room, register_player, spawn_forwarder, broadcast_room_state, spawn_message_handler, remove_player.

Use Game::process_throw in the message handler.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:49:56 -07:00
eros
1f673b4eba refactor(backend): encapsulate throw lifecycle and clean dead code in game.rs
Add Game::process_throw to centralize throw→simulation→scoring→state flow.

Remove dead room_tx field, simplify add_player, and rename end_ends_or_continue.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:46:12 -07:00
20 changed files with 1078 additions and 622 deletions

View File

@ -1,6 +1,6 @@
## curltastic
A 1v1 async multiplayer 2D curling game for mobile browser.
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).
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
@ -21,22 +21,24 @@ A 1v1 async multiplayer 2D curling game for mobile browser.
npm run dev
# opens on 0.0.0.0:5173 by default
```
4. Open two browser tabs to the same room URL, e.g.:
4. Open one or more browser tabs to the same room URL, e.g.:
```
http://localhost:5173/?room=DEMO1
```
The first tab waits; the second starts the match. Use the *Copy share link* button to invite an opponent.
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.
### 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 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
- Drag inside the house to place the broom (aim point).
- Tap a weight `1``10`.
- Tap **THROW**.
- The server runs the physics and streams the trajectory; the frontend interpolates the animation.
- When it is your team's turn, drag on the sheet to place the broom (aim point) — 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).
- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar.
- Tap **THROW**. Anyone identifying as the 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.
### Architecture
@ -52,8 +54,12 @@ With the backend and frontend dev server running:
```bash
cd e2e
npm install -g ws # or npm install ws locally in the project
node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal
node e2e_score.cjs # stones remain in play and alternate turns
node e2e_test.cjs # room lifecycle, throw, trajectory
node e2e_score.cjs # alternate turns / stones in play
node e2e_persistence.cjs # multi-throw stone persistence
node e2e_multi_client.cjs # 3 clients share state (no room-full)
node e2e_end_score.cjs # full end → end_scored + next end
node collision_trajectory_qa.cjs # multi-stone trajectory on collision
```
### Limitations / known simplifications

View File

@ -12,8 +12,6 @@ pub enum GamePhase {
}
pub struct Game {
red: Option<Player>,
yellow: Option<Player>,
phase: GamePhase,
end: u8,
scores: [i32; 2],
@ -24,14 +22,18 @@ pub struct Game {
stones_red: u8,
stones_yellow: u8,
last_end_scored: Option<(u8, i32, Option<Team>)>,
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
}
pub struct ThrowOutcome {
pub trajectory: Vec<StoneTrajectory>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>,
}
impl Game {
pub fn new() -> Self {
Self {
red: None,
yellow: None,
phase: GamePhase::Waiting,
end: 1,
scores: [0, 0],
@ -42,81 +44,10 @@ impl Game {
stones_red: STONES_PER_TEAM,
stones_yellow: STONES_PER_TEAM,
last_end_scored: None,
room_tx: None,
}
}
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
let team = match preferred {
Some(Team::Red) if self.red.is_none() => Some(Team::Red),
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow),
Some(_) => {
// Preferred is taken; fall back to the other free team.
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} else {
None
}
}
None => {
// Legacy first-free logic.
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} else {
None
}
}
};
if let Some(t) = team {
let player = Player { id, team: t, connected: true };
match t {
Team::Red => self.red = Some(player),
Team::Yellow => self.yellow = Some(player),
}
Some(t)
} else {
None
}
}
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
if let Some(ref p) = self.red {
if p.id == id {
self.red = None;
return Some(Team::Red);
}
}
if let Some(ref p) = self.yellow {
if p.id == id {
self.yellow = None;
return Some(Team::Yellow);
}
}
None
}
pub fn set_player_connected(&mut self, id: &str, connected: bool) {
if let Some(p) = self.red.as_mut().filter(|p| p.id == id) {
p.connected = connected;
}
if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) {
p.connected = connected;
}
}
pub fn can_start(&self) -> bool {
self.red.is_some() && self.yellow.is_some()
}
pub fn start(&mut self) {
if !self.can_start() {
return;
}
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing;
@ -129,37 +60,24 @@ impl Game {
self.active_stones.clear();
}
pub fn current_player_id(&self) -> Option<&str> {
let p = match self.turn_team {
Team::Red => self.red.as_ref()?,
Team::Yellow => self.yellow.as_ref()?,
};
Some(&p.id)
}
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
None
}
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
let current_id = self.current_player_id().ok_or("No current player")?;
if current_id != player_id {
pub fn handle_throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
weight: u8,
curl: i8,
friction: f32,
) -> Result<Vec<StoneTrajectory>, String> {
if self.turn_team != team {
return Err("Not your turn".to_string());
}
if self.phase != GamePhase::Playing {
return Err("Cannot throw now".to_string());
}
let dx = broom_x - HOUSE_CENTER.0;
let dy = broom_y - HOUSE_CENTER.1;
let dist = (dx * dx + dy * dy).sqrt();
if dist > HOUSE_RADIUS {
return Err("Broom must be in the house".to_string());
}
self.active_stones.clear();
let path = 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, weight, curl, friction)?;
self.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating;
@ -168,7 +86,35 @@ impl Game {
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
}
Ok(path)
Ok(trajectory)
}
pub fn process_throw(
&mut self,
team: Team,
broom_x: f32,
broom_y: f32,
weight: u8,
curl: i8,
friction: f32,
) -> Result<ThrowOutcome, String> {
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
self.finish_simulation();
let end_scored = self.take_last_end_scored();
let state_message = self.game_state_message();
let game_over = if self.phase == GamePhase::GameComplete {
Some(self.game_over_message())
} else {
None
};
Ok(ThrowOutcome {
trajectory,
end_scored,
state_message,
game_over,
})
}
pub fn finish_simulation(&mut self) {
@ -179,10 +125,6 @@ impl Game {
self.score_end_internal(false);
}
pub fn score_end(&mut self) {
self.score_end_internal(true);
}
fn score_end_internal(&mut self, force: bool) {
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
if !end_done {
@ -220,7 +162,7 @@ impl Game {
Team::Red => 0,
Team::Yellow => 1,
};
self.scores[team_idx] += points as i32;
self.scores[team_idx] += points;
self.hammer = team.other();
} else {
points = 0;
@ -229,10 +171,10 @@ impl Game {
self.last_end_scored = Some((self.end, points, scoring_team));
self.phase = GamePhase::EndComplete;
self.end_ends_or_continue(points, scoring_team);
self.advance_end_or_finish();
}
fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option<Team>) {
fn advance_end_or_finish(&mut self) {
let tied = self.scores[0] == self.scores[1];
let after_regulation = self.end >= ENDS;
@ -241,10 +183,6 @@ impl Game {
return;
}
if after_regulation && tied {
// Extra end
}
self.end += 1;
self.stones_red = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM;
@ -255,10 +193,6 @@ impl Game {
self.phase = GamePhase::Playing;
}
pub fn phase(&self) -> GamePhase {
self.phase
}
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 }
@ -300,24 +234,18 @@ impl Game {
}
}
pub type RoomId = String;
pub struct Room {
pub id: RoomId,
pub game: Game,
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
}
impl Room {
pub fn new(id: &str) -> Self {
pub fn new(_id: &str) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(256);
let mut room = Self {
id: id.to_string(),
Self {
game: Game::new(),
tx,
};
room.game.room_tx = Some(room.tx.clone());
room
}
}
}
@ -326,29 +254,46 @@ mod tests {
use super::*;
#[test]
fn prefers_red_when_requested_and_free() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
fn starts_in_waiting_phase() {
let game = Game::new();
assert!(matches!(game.phase, GamePhase::Waiting));
}
#[test]
fn prefers_yellow_when_requested_and_free() {
fn starts_when_called() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
game.start();
assert!(matches!(game.phase, GamePhase::Playing));
assert_eq!(game.end, 1);
assert_eq!(game.scores, [0, 0]);
}
#[test]
fn falls_back_when_preferred_taken() {
fn rejects_throw_for_wrong_team() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
game.start();
let turn = game.turn_team;
let wrong = turn.other();
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
assert!(result.is_err());
}
#[test]
fn legacy_order_without_preference() {
fn accepts_throw_for_turn_team() {
let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
assert_eq!(game.add_player("p3".into(), None), None);
game.start();
let turn = game.turn_team;
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
assert!(result.is_ok());
}
#[test]
fn accepts_broom_outside_house() {
let mut game = Game::new();
game.start();
let turn = game.turn_team;
// (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);
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
}
}

View File

@ -13,30 +13,35 @@ use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
use futures_util::{sink::SinkExt, stream::StreamExt};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use tokio::sync::Mutex as TokioMutex;
use std::sync::Arc;
use tokio::sync::Mutex;
use tracing::{info, warn};
use uuid::Uuid;
use crate::game::{GamePhase, Room};
use crate::game::{Room, ThrowOutcome};
use crate::protocol::*;
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
#[derive(Clone)]
struct AppState {
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
}
impl AppState {
fn new() -> Self {
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
Self {
rooms: Arc::new(Mutex::new(HashMap::new())),
}
}
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
let mut rooms = self.rooms.lock().unwrap();
async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
let mut rooms = self.rooms.lock().await;
if let Some(room) = rooms.get(room_id).cloned() {
return room;
}
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
let room = Arc::new(Mutex::new(Room::new(room_id)));
rooms.insert(room_id.to_string(), room.clone());
room
}
@ -45,7 +50,6 @@ impl AppState {
#[derive(Deserialize)]
struct RoomQuery {
room: String,
team: Option<Team>,
}
#[derive(Serialize)]
@ -81,7 +85,7 @@ async fn health() -> impl IntoResponse {
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let room_id = generate_room_code();
state.get_or_create_room(&room_id);
state.get_or_create_room(&room_id).await;
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
}
@ -90,53 +94,50 @@ async fn ws_handler(
Query(query): Query<RoomQuery>,
State(state): State<Arc<AppState>>,
) -> impl IntoResponse {
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room))
}
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String, preferred_team: Option<Team>) {
let room_arc = state.get_or_create_room(&room_id);
let player_id = Uuid::new_v4().to_string();
let player_id_for_recv = player_id.clone();
async fn handle_socket(
socket: WebSocket,
state: Arc<AppState>,
room_id: String,
) {
let room = state.get_or_create_room(&room_id).await;
let (mut sender, receiver) = socket.split();
let (mut sender, mut receiver) = socket.split();
// Reject room-full before subscribing so the error goes only to the joining socket.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let err = serde_json::to_string(&ServerMessage::Error {
message: "Room is full".to_string(),
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
let mut room_guard = room.lock().await;
if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) {
room_guard.game.start();
}
}
let joined = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(),
})
.unwrap();
if sender.send(Message::Text(Utf8Bytes::from(joined))).await.is_err() {
return;
}
let tx = { room.lock().await.tx.clone() };
let send_task = spawn_forwarder(sender, tx.subscribe());
broadcast_room_state(&room, &tx).await;
let recv_task = spawn_message_handler(room.clone(), tx, receiver);
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
}
// Add the player to the room and tell only this socket its assigned team.
let team = {
let mut room = room_arc.lock().await;
let team = room.game.add_player(player_id.clone(), preferred_team)
.unwrap_or(Team::Red);
if room.game.can_start() {
room.game.start();
}
team
};
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(),
team,
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
// Subscribe to broadcast and spawn the forwarding task.
let tx = {
let room = room_arc.lock().await;
room.tx.clone()
};
let mut rx = tx.subscribe();
let send_task = tokio::spawn(async move {
fn spawn_forwarder(
mut sender: WsSender,
mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop {
match rx.recv().await {
Ok(msg) => {
@ -151,49 +152,51 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
Err(_) => break,
}
}
});
// Broadcast waiting/game state to everyone in the room.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let state_msg = room.game.game_state_message();
let _ = room.tx.send(state_msg);
} else {
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
}
})
}
let recv_room = room_arc.clone();
let recv_id = player_id_for_recv;
let recv_task = tokio::spawn(async move {
async fn broadcast_room_state(
room: &Arc<Mutex<Room>>,
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
) {
let room_guard = room.lock().await;
let _ = tx.send(room_guard.game.game_state_message());
}
fn spawn_message_handler(
room: Arc<Mutex<Room>>,
tx: tokio::sync::broadcast::Sender<ServerMessage>,
mut receiver: WsReceiver,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await {
let Message::Text(text) = msg else { continue; };
let Message::Text(text) = msg else { continue };
let text_ref = text.as_str();
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
match parsed {
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
let room = recv_room.clone();
let mut room = room.lock().await;
if room.game.current_team_for_player(&recv_id) != Some(team) {
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() });
continue;
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
let mut room_guard = room.lock().await;
match room_guard
.game
.process_throw(team, broom_x, broom_y, weight, curl, friction)
{
Ok(ThrowOutcome {
trajectory,
end_scored,
state_message,
game_over,
}) => {
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
if let Some(scored) = end_scored {
let _ = tx.send(scored);
}
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) {
Ok(path) => {
room.tx.send(ServerMessage::Trajectory { path }).ok();
room.game.finish_simulation();
if let Some(scored) = room.game.take_last_end_scored() {
room.tx.send(scored).ok();
}
let after = room.game.game_state_message();
room.tx.send(after).ok();
if room.game.phase() == GamePhase::GameComplete {
room.tx.send(room.game.game_over_message()).ok();
let _ = tx.send(state_message);
if let Some(over) = game_over {
let _ = tx.send(over);
}
}
Err(e) => {
room.tx.send(ServerMessage::Error { message: e }).ok();
let _ = tx.send(ServerMessage::Error { message: e });
}
}
}
@ -202,16 +205,5 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
}
}
}
});
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
// On disconnect, free the team slot so refreshes and new tabs can rejoin.
{
let mut room = room_arc.lock().await;
let _ = room.game.remove_player(&player_id);
}
})
}

View File

@ -107,7 +107,7 @@ impl PhysicsWorld {
weight: u8,
curl: i8,
friction: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
) -> Result<Vec<StoneTrajectory>, String> {
let weight = weight.clamp(1, 10) as f32;
let t = (weight - 1.0) / 9.0;
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
@ -132,7 +132,7 @@ impl PhysicsWorld {
vy: f32,
curl_sign: i8,
damping_mult: f32,
) -> Result<Vec<(f32, f32, f32)>, String> {
) -> Result<Vec<StoneTrajectory>, String> {
let id = self.next_stone_id;
self.next_stone_id += 1;
@ -161,15 +161,31 @@ impl PhysicsWorld {
self.simulate_until_rest(id)
}
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
let mut path: Vec<(f32, f32, f32)> = Vec::new();
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
// All paths share the thrown stone's release instant as t=0. This keeps the
// frontend's existing trajectory helpers (which expect the thrown stone to
// start at x=0, y=HACK_Y with t=0) working unchanged while also giving every
// other stone a consistent timeline.
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
let mut sample_accum: f32 = 0.0;
let mut time: f32 = 0.0;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
let body = &self.bodies[*h];
path.push((body.translation().x, body.translation().y, time));
// Pre-allocate a path buffer for every stone currently in the world.
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
.stone_handles
.iter()
.map(|(id, handle, _, _)| (*id, *handle, Vec::new()))
.collect();
// Record the initial sample at t=0 for every stone.
for (id, handle, path) in &mut paths {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
path.push((pos.x, pos.y, time));
} else {
// Body missing for an tracked stone; this should not happen.
return Err(format!("stone {} has no rigid body", id));
}
}
loop {
@ -180,9 +196,11 @@ impl PhysicsWorld {
if sample_accum >= sample_step {
sample_accum -= sample_step;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
let body = &self.bodies[*h];
path.push((body.translation().x, body.translation().y, time));
for (_, handle, path) in &mut paths {
if let Some(body) = self.bodies.get(*handle) {
let pos = body.translation();
path.push((pos.x, pos.y, time));
}
}
}
@ -193,7 +211,27 @@ impl PhysicsWorld {
self.prune_out_of_play();
Ok(path)
// The thrown stone is released at (0.0, HACK_Y). Shift every path in time so
// that t=0 corresponds to that release instant. Because we already started
// sampling at the release instant, the first sample time is 0.0 and no shift
// is required; this comment documents the invariant.
let thrown_first_t = paths
.iter()
.find(|(id, _, _)| *id == thrown_id)
.and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t))
.unwrap_or(0.0);
Ok(paths
.into_iter()
.map(|(id, _, mut path)| {
if thrown_first_t != 0.0 {
for (_, _, t) in &mut path {
*t -= thrown_first_t;
}
}
StoneTrajectory { stone_id: id, path }
})
.collect())
}
// Rotate each stone's velocity slightly based on its selected curl direction.
@ -377,4 +415,70 @@ mod tests {
let stones = world.current_stones();
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
}
#[test]
fn collision_records_trajectories_for_both_stones() {
// Place a stationary stone on the center line and throw a second stone
// straight at it so they collide. Both stones must have sampled paths.
let mut world = PhysicsWorld::new();
// First stone: place it far enough up-sheet to stay in play after impact.
world
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
.unwrap();
let first_id = world.next_stone_id - 1;
// Second stone: aimed directly at the first stone's final position.
let target_y = final_y(&world, first_id);
let target_x = final_x(&world, first_id);
world
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
.unwrap();
let second_id = world.next_stone_id - 1;
// Re-run the collision throw and capture trajectories.
let mut world = PhysicsWorld::new();
world
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
.unwrap();
let first_id = world.next_stone_id - 1;
let target_y = final_y(&world, first_id);
let target_x = final_x(&world, first_id);
let trajectories = world
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
.unwrap();
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
.into_iter()
.map(|st| (st.stone_id, st.path))
.collect();
assert!(
by_id.contains_key(&first_id),
"trajectories should contain the first stone (id={})",
first_id
);
assert!(
by_id.contains_key(&second_id),
"trajectories should contain the thrown stone (id={})",
second_id
);
let first_path = by_id.get(&first_id).unwrap();
let second_path = by_id.get(&second_id).unwrap();
assert!(
first_path.len() > 1,
"first stone path should have multiple samples, got {}",
first_path.len()
);
assert!(
second_path.len() > 1,
"thrown stone path should have multiple samples, got {}",
second_path.len()
);
// Both paths should share the same t=0 reference (the thrown stone's release).
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0");
}
}

View File

@ -4,6 +4,7 @@ use std::fmt;
pub const TICK_RATE_HZ: u16 = 120;
pub const SAMPLE_RATE_HZ: u16 = 40;
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
pub const FEET_TO_METERS: f32 = 0.3048;
pub const STONES_PER_TEAM: u8 = 8;
pub const ENDS: u8 = 10;
@ -12,11 +13,7 @@ pub const ENDS: u8 = 10;
pub const SHEET_WIDTH: f32 = 5.0;
pub const SHEET_LENGTH: f32 = 45.0;
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
pub const FOUR_FT_RADIUS: f32 = 0.6096;
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
pub const HOG_LINE_Y: f32 = 21.0;
pub const BACK_LINE_Y: f32 = 42.0;
pub const HACK_Y: f32 = 2.0;
@ -59,6 +56,7 @@ impl fmt::Display for Team {
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage {
Throw {
team: Team,
broom_x: f32,
broom_y: f32,
weight: u8,
@ -75,7 +73,7 @@ fn default_friction() -> f32 { 1.0 }
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage {
Joined { room: String, team: Team },
Joined { room: String },
Waiting { message: String },
GameState {
end: u8,
@ -86,7 +84,7 @@ pub enum ServerMessage {
phase: Phase,
},
Trajectory {
path: Vec<(f32, f32, f32)>,
paths: Vec<StoneTrajectory>,
},
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver {
@ -108,6 +106,12 @@ pub enum Phase {
GameComplete,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneTrajectory {
pub stone_id: u32,
pub path: Vec<(f32, f32, f32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState {
pub id: u32,
@ -117,10 +121,3 @@ pub struct StoneState {
pub rotation: f32,
pub active: bool,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Player {
pub id: String,
pub team: Team,
pub connected: bool,
}

View File

@ -0,0 +1,72 @@
const WebSocket = require('ws')
const room = 'COLQA' + Math.floor(Math.random() * 1000)
const url = 'ws://127.0.0.1:3000/ws?room=' + room
function connect() {
return new Promise((resolve, reject) => {
const ws = new WebSocket(url)
const messages = []
ws.on('open', () => resolve({ ws, messages }))
ws.on('message', (data) => messages.push(JSON.parse(data.toString())))
ws.on('error', reject)
})
}
function waitFor(messages, pred, timeout = 10000) {
const start = Date.now()
return new Promise((resolve, reject) => {
const check = () => {
if (pred()) return resolve(undefined)
if (Date.now() - start > timeout) return reject(new Error('timeout'))
setTimeout(check, 50)
}
check()
})
}
;(async () => {
const c = await connect()
await waitFor(c.messages, () => {
const last = c.messages[c.messages.length - 1]
return last && last.type === 'game_state' && last.phase === 'playing'
})
// First throw: weight 7 so it stays in house.
let state = c.messages[c.messages.length - 1]
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 }))
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'game_state').length > 1)
state = c.messages[c.messages.length - 1]
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')
const trajCountBefore = c.messages.filter(m => m.type === 'trajectory').length
console.log('trajectory count before second throw:', trajCountBefore)
// Second throw aimed slightly off-center so it hits the first stone.
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 }))
await waitFor(c.messages, () => c.messages.filter(m => m.type === 'trajectory').length > trajCountBefore)
const traj = c.messages.filter(m => m.type === 'trajectory').pop()
console.log('Trajectory paths count:', traj.paths.length)
for (const p of traj.paths) {
console.log('stone_id', p.stone_id, 'path length', p.path.length, 'first', p.path[0], 'last', p.path[p.path.length - 1])
}
const ids = traj.paths.map(p => p.stone_id).sort((a, b) => a - b)
if (ids.length !== 2 || ids[0] !== 1 || ids[1] !== 2) throw new Error('expected both stone ids in trajectory, got ' + JSON.stringify(ids))
for (const p of traj.paths) {
if (p.path.length < 5) throw new Error('path too short for stone ' + p.stone_id)
}
// Verify the first stone actually moved because of collision.
const stone1Path = traj.paths.find(p => p.stone_id === 1).path
const first = stone1Path[0]
const last = stone1Path[stone1Path.length - 1]
const dist = Math.sqrt((last[0]-first[0])**2 + (last[1]-first[1])**2)
console.log('stone1 moved', dist, 'm')
if (dist < 0.05) throw new Error('expected first stone to move after collision')
console.log('COLLISION TRAJECTORY QA PASSED')
c.ws.close()
process.exit(0)
})().catch(e => { console.error(e); process.exit(1) })

View File

@ -9,7 +9,7 @@ function connect(name, room) {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
messages.push(msg)
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
if (msg.type === 'joined') resolve({ ws, messages })
})
ws.on('error', reject)
})
@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) {
}, 5000)
const turn = getTurn()
if (!turn) throw new Error('no turn')
const thrower = turn === p1.team ? p1 : p2
const broomX = (Math.random() - 0.5) * 0.6
thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
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)
}

75
e2e/e2e_multi_client.cjs Normal file
View File

@ -0,0 +1,75 @@
const WebSocket = require('ws')
const room = 'MULTI' + Math.floor(Math.random() * 1000)
const base = 'ws://127.0.0.1:3000/ws?room=' + room
function connect(name) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(base)
const messages = []
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
messages.push(msg)
console.log(`[${name}]`, msg.type)
if (msg.type === 'joined') {
resolve({ ws, messages })
}
})
ws.on('open', () => console.log(`[${name}] open`))
ws.on('error', (e) => { console.error(`[${name}] error`, e.message); reject(e) })
ws.on('close', (code) => console.log(`[${name}] close`, code))
})
}
function waitFor(client, pred, timeout = 10000) {
const start = Date.now()
return new Promise((resolve, reject) => {
const check = () => {
if (pred(client.messages)) return resolve(undefined)
if (Date.now() - start > timeout) return reject(new Error('timeout'))
setTimeout(check, 50)
}
check()
})
}
;(async () => {
// 3 clients join the same room
const p1 = await connect('p1')
const p2 = await connect('p2')
const p3 = await connect('p3')
// All 3 receive game_state after join
await waitFor(p1, msgs => msgs.some(m => m.type === 'game_state'), 5000)
await waitFor(p2, msgs => msgs.some(m => m.type === 'game_state'), 5000)
await waitFor(p3, msgs => msgs.some(m => m.type === 'game_state'), 5000)
console.log('All 3 clients received game_state')
// p1 throws for the current turn_team
const state = p1.messages.find(m => m.type === 'game_state')
const turn = state.turn_team
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 }))
// All 3 clients eventually see trajectory or updated game_state
await waitFor(p1, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
await waitFor(p2, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
await waitFor(p3, msgs => msgs.some(m => m.type === 'trajectory'), 15000)
console.log('All 3 clients received trajectory')
// 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(p2, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
await waitFor(p3, msgs => msgs.slice(-1)[0]?.type === 'game_state', 15000)
console.log('All 3 clients received updated game_state after throw')
console.log('Multi-client observer test passed')
p1.ws.close()
p2.ws.close()
p3.ws.close()
process.exit(0)
})().catch(err => {
console.error(err)
process.exit(1)
})

View File

@ -12,7 +12,7 @@ function connect(name) {
messages.push(msg)
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
if (msg.type === 'joined') {
resolve({ ws, messages, team: msg.team })
resolve({ ws, messages })
}
})
ws.on('open', () => console.log(`[${name}] open`))
@ -38,27 +38,23 @@ function latestState(messages) {
;(async () => {
const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
const p2 = await connect('p2')
await waitFor(() => p2.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)
console.log('Initial state', state)
// First throw: yellow aims slightly left of center.
// First throw: current turn team.
let turn = state.turn_team
let turnPlayer = turn === p1.team ? p1 : p2
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
state = latestState(p1.messages)
console.log('After first throw:', state)
// Second throw: red aims slightly right of center.
// Second throw: other team.
turn = state.turn_team
turnPlayer = turn === p1.team ? p1 : p2
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
state = latestState(p1.messages)
@ -77,7 +73,6 @@ function latestState(messages) {
console.log('PERSISTENCE E2E PASSED')
p1.ws.close()
p2.ws.close()
process.exit(0)
})().catch(err => {
console.error(err)

View File

@ -1,47 +0,0 @@
const WebSocket = require('ws')
const base = (room) => `ws://127.0.0.1:3000/ws?room=${room}`
function connect(name, room) {
return new Promise((resolve, reject) => {
const ws = new WebSocket(base(room))
const messages = []
ws.on('open', () => resolve({ ws, messages }))
ws.on('message', (data) => messages.push(JSON.parse(data.toString())))
ws.on('error', reject)
})
}
function waitFor(messages, pred, timeout = 10000) {
const start = Date.now()
return new Promise((resolve, reject) => {
const check = () => {
if (pred()) return resolve(undefined)
if (Date.now() - start > timeout) return reject(new Error('timeout'))
setTimeout(check, 50)
}
check()
})
}
;(async () => {
const room = 'ROOMFULL' + Math.floor(Math.random() * 1000)
const p1 = await connect('p1', room)
const p2 = await connect('p2', room)
await waitFor(p1.messages, () => p1.messages.some(m => m.type === 'game_state'), 5000)
const p3 = await connect('p3', room)
await waitFor(p3.messages, () => p3.messages.some(m => m.type === 'error'), 2000)
const p3error = p3.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
const p1error = p1.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
const p2error = p2.messages.some(m => m.type === 'error' && m.message.includes('Room is full'))
console.log({ p3error, p1error, p2error })
if (!p3error) throw new Error('p3 should get room full error')
if (p1error || p2error) throw new Error('existing players should not see room-full error')
p1.ws.close()
p2.ws.close()
p3.ws.close()
process.exit(0)
})().catch((e) => {
console.error(e)
process.exit(1)
})

View File

@ -9,7 +9,7 @@ function connect(name) {
ws.on('message', (data) => {
const msg = JSON.parse(data.toString())
messages.push(msg)
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
if (msg.type === 'joined') resolve({ ws, messages })
})
ws.on('error', reject)
})
@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) {
;(async () => {
const p1 = await connect('p1')
const p2 = await connect('p2')
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
let state = p1.messages.find(m => m.type === 'game_state')
console.log('start turn', state.turn_team, 'hammer', state.hammer)
const thrower1 = state.turn_team === p1.team ? p1 : p2
thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
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 }))
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
await new Promise(r => setTimeout(r, 500))
state = p1.messages.slice(-1)[0]
console.log('After first throw:', state)
const thrower2 = state.turn_team === p1.team ? p1 : p2
thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
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 }))
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
await new Promise(r => setTimeout(r, 500))
console.log('Final stones', p1.messages.slice(-1)[0].stones)
p1.ws.close()
p2.ws.close()
process.exit(0)
})().catch(e => {
console.error(e)

View File

@ -12,7 +12,7 @@ function connect(name) {
messages.push(msg)
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '')
if (msg.type === 'joined') {
resolve({ ws, messages, team: msg.team })
resolve({ ws, messages })
}
})
ws.on('open', () => console.log(`[${name}] open`))
@ -21,31 +21,30 @@ function connect(name) {
})
}
async function waitFor(condFn, timeout = 5000) {
function waitFor(condFn, timeout = 5000) {
const start = Date.now()
while (!condFn()) {
if (Date.now() - start > timeout) throw new Error('Timeout waiting')
await new Promise(r => setTimeout(r, 50))
return new Promise((resolve, reject) => {
const check = () => {
if (condFn()) return resolve(undefined)
if (Date.now() - start > timeout) return reject(new Error('Timeout waiting'))
setTimeout(check, 50)
}
check()
})
}
;(async () => {
const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
const p2 = await connect('p2')
await waitFor(() => p2.messages.some(m => m.type === 'joined'))
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
const state = p1.messages.find(m => m.type === 'game_state')
console.log('Game state', state)
const turn = state.turn_team
const turnPlayer = turn === p1.team ? p1 : p2
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
console.log('Final state after throw:', p1.messages.slice(-1)[0])
p1.ws.close()
p2.ws.close()
process.exit(0)
})().catch(err => {
console.error(err)

View File

@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import { GameModel } from './game-model'
import type { ServerStoneTrajectory, StoneState } from './protocol'
function stone(partial: Partial<StoneState> & Pick<StoneState, 'id' | 'team'>): StoneState {
return {
x: 0,
y: 30,
rotation: 0,
active: true,
...partial,
}
}
describe('GameModel multi-path trajectory animation', () => {
it('returns two DrawableStones in parallel mid-trajectory', () => {
const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' }), stone({ id: 2, team: 'yellow' })]
model.state.turnTeam = 'red'
const paths: ServerStoneTrajectory[] = [
{
stone_id: 1,
path: [
[0, 10, 0],
[0, 11, 0.5],
[0, 12, 1.0],
],
},
{
stone_id: 2,
path: [
[1, 10, 0],
[1, 11, 0.5],
[1, 12, 1.0],
],
},
]
model.startTrajectory(paths)
expect(model.state.animating).toBe(true)
const mid = performance.now() + 500
const drawn = model.tick(mid)
expect(drawn).toHaveLength(2)
expect(drawn.map((d) => d.team).sort()).toEqual(['red', 'yellow'])
// Mid-sample y ≈ 11 for both paths (y never reaches hog line → no trim shift)
for (const d of drawn) {
expect(d.y).toBeCloseTo(11, 0)
}
})
it('clears animating when elapsed reaches maxTotal on a short path', () => {
const model = new GameModel()
model.state.stones = [stone({ id: 1, team: 'red' })]
model.state.turnTeam = 'red'
model.startTrajectory([
{
stone_id: 1,
path: [
[0, 10, 0],
[0, 10.5, 0.2],
[0, 11, 0.4],
],
},
])
expect(model.state.animating).toBe(true)
const afterEnd = performance.now() + 500
const drawn = model.tick(afterEnd)
expect(drawn).toEqual([])
expect(model.state.animating).toBe(false)
})
it('trims thrown stone path to hog line when id is not in existing stones', () => {
const model = new GameModel()
// Only stone 1 is already on the sheet; stone 2 is the newly thrown rock.
model.state.stones = [stone({ id: 1, team: 'yellow', x: 0.5, y: 35 })]
model.state.turnTeam = 'red'
const thrownPath: [number, number, number][] = [
[0, 2, 0],
[0, 20, 1],
[0, 21.5, 2],
[0, 30, 3],
]
model.startTrajectory([{ stone_id: 2, path: thrownPath }])
expect(model.state.animating).toBe(true)
// Immediately after start: hog-trimmed path begins at y=20 (sample before hog), t=0
const atStart = performance.now()
const drawn = model.tick(atStart)
expect(drawn).toHaveLength(1)
expect(drawn[0].team).toBe('red') // turnTeam fallback for unknown id
expect(drawn[0].y).toBeCloseTo(20, 0)
// Must not still be at the hack (y=2)
expect(drawn[0].y).toBeGreaterThan(15)
})
})

182
frontend/src/game-model.ts Normal file
View File

@ -0,0 +1,182 @@
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
import { trimPathToStartAtHogLine } from './game-helpers'
export interface GameModelState {
end: number
scores: number[]
hammer: Team
turnTeam: Team
myTeam: Team | null
phase: Phase
stones: StoneState[]
animating: boolean
}
export class GameModel {
state: GameModelState = {
end: 1,
scores: [0, 0],
hammer: 'red',
turnTeam: 'red',
myTeam: null,
phase: 'waiting',
stones: [],
animating: false,
}
pendingStones: StoneState[] = []
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
isDragging = false
isPanning = false
private activePaths = new Map<number, [number, number, number][]>()
private animationStartTime = 0
setMyTeam(team: Team): void {
this.state.myTeam = team
}
setWaiting(): void {
this.state.phase = 'waiting'
}
updateGameState(msg: ServerGameStateMessage): void {
const reset = msg.phase === 'waiting' || msg.end !== this.state.end
if (reset) {
this.state.stones = []
this.pendingStones = []
}
if (this.state.animating) {
this.pendingStones = msg.stones
} else {
this.state.stones = msg.stones
}
this.state = {
...this.state,
end: msg.end,
scores: msg.scores,
hammer: msg.hammer,
turnTeam: msg.turn_team,
phase: msg.phase,
}
}
startTrajectory(paths: ServerStoneTrajectory[]): void {
const existingIds = new Set(this.state.stones.map((s) => s.id))
let thrownId: number | null = null
for (const { stone_id } of paths) {
if (!existingIds.has(stone_id)) {
thrownId = stone_id
break
}
}
if (thrownId === null && paths.length > 0) {
thrownId = paths[0].stone_id
}
let tRef = 0
if (thrownId !== null) {
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
if (thrownPath.length >= 2) {
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][]>()
for (const { stone_id, path } of paths) {
if (stone_id === thrownId) {
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
} else {
const shifted = path
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
.filter(([, , t]) => t >= 0)
pathMap.set(stone_id, shifted)
}
}
this.activePaths = pathMap
this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
this.animationStartTime = performance.now()
this.pendingStones = []
}
tick(now: number): DrawableStone[] {
if (!this.state.animating) {
return []
}
const elapsed = (now - this.animationStartTime) / 1000
const maxTotal = Math.max(
0,
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
)
if (elapsed >= maxTotal) {
this.state.animating = false
if (this.pendingStones.length > 0) {
this.state.stones = this.pendingStones
this.pendingStones = []
}
return []
}
const result: DrawableStone[] = []
for (const [stoneId, path] of this.activePaths) {
const pos = this.interpolatePath(path, elapsed)
if (!pos) continue
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam
result.push({ ...pos, team })
}
return result
}
private interpolatePath(
path: [number, number, number][],
elapsed: number,
): { x: number; y: number; rotation: number } | null {
if (path.length === 0) return null
if (path.length === 1) {
const [x, y] = path[0]
return { x, y, rotation: 0 }
}
if (elapsed >= path[path.length - 1][2]) {
const last = path[path.length - 1]
const prev = path[path.length - 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 }
}
let i = 0
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
const p0 = path[i]
const p1 = path[i + 1] ?? p0
const t0 = path[Math.max(i - 1, 0)]
const t2 = path[Math.min(i + 2, path.length - 1)]
const dt = p1[2] - p0[2]
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t
const y = p0[1] + (p1[1] - p0[1]) * t
const dx = t2[0] - t0[0]
const dy = t2[1] - t0[1]
const rotation = Math.atan2(dy, dx) * 2
return { x, y, rotation }
}
get isMyTurn(): boolean {
return Boolean(
this.state.myTeam &&
this.state.turnTeam === this.state.myTeam &&
this.state.phase === 'playing' &&
!this.state.animating,
)
}
}

View File

@ -1,19 +1,8 @@
import { connect, sendThrow, type NetCallbacks } from './net'
import { createRenderer } from './renderer'
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
import { trimPathToStartAtHogLine } from './game-helpers'
interface GameState {
end: number
scores: number[]
hammer: Team
turnTeam: Team
myTeam: Team | null
phase: Phase
stones: StoneState[]
animating: boolean
}
import { type Team } from './protocol'
import { GameModel } from './game-model'
export function startGame(): void {
const app = document.querySelector<HTMLDivElement>('#app')!
@ -24,6 +13,10 @@ export function startGame(): void {
const hud = createHud()
app.appendChild(hud.root)
if (import.meta.env.DEV) {
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
}
const velocityContainer = hud.velocityControl
const curlContainer = hud.curlSelector
const frictionContainer = hud.frictionControl
@ -42,141 +35,65 @@ export function startGame(): void {
const shareLink = `${window.location.origin}/?room=${room}`
hud.setShareLink(shareLink)
const picker = document.createElement('div')
picker.id = 'team-picker'
picker.innerHTML = `
<div class="team-picker-box">
<h2>Choose your team</h2>
<div class="team-picker-buttons">
<button class="team-btn team-red" data-team="red">Play as Red</button>
<button class="team-btn team-yellow" data-team="yellow">Play as Yellow</button>
</div>
</div>
`
app.appendChild(picker)
const model = new GameModel()
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
btn.addEventListener('click', () => {
const team = btn.dataset.team as Team
picker.remove()
connect(room, team, callbacks)
})
})
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
model.setMyTeam(initialTeam)
hud.setTeam(initialTeam)
let gameState: GameState = {
end: 1,
scores: [0, 0],
hammer: 'red',
turnTeam: 'red',
myTeam: null,
phase: 'waiting',
stones: [],
animating: false,
}
let pendingStones: StoneState[] = []
let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
let isDragging = false
let activePath: [number, number, number][] = []
let animationStartTime = 0
let animationTeam: Team = 'red'
// connect() is invoked after callbacks is defined below.
const updateControls = () => {
const myTurn = Boolean(
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
)
velocity.setEnabled(myTurn && !gameState.animating)
curls.setEnabled(myTurn && !gameState.animating)
friction.setEnabled(myTurn && !gameState.animating)
throwBtn.disabled = !myTurn || gameState.animating
if (gameState.phase === 'simulating' || gameState.animating) {
velocity.setEnabled(false)
curls.setEnabled(false)
friction.setEnabled(false)
}
hud.update(gameState)
const activeTeam = hud.teamSelect.value as Team
model.setMyTeam(activeTeam)
const myTurn = model.isMyTurn
velocity.setEnabled(myTurn)
curls.setEnabled(myTurn)
friction.setEnabled(myTurn)
throwBtn.disabled = !myTurn
hud.update(model.state)
}
const render = () => {
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
if (gameState.animating && activePath.length > 1) {
const elapsed = (performance.now() - animationStartTime) / 1000
const total = activePath[activePath.length - 1][2]
if (elapsed >= total) {
gameState.animating = false
if (pendingStones.length > 0) {
gameState.stones = pendingStones
pendingStones = []
}
hud.teamSelect.addEventListener('change', () => {
localStorage.setItem('curltastic-team', hud.teamSelect.value)
updateControls()
})
const render = () => {
const wasAnimating = model.state.animating
const activeStonePos = model.tick(performance.now())
if (wasAnimating && !model.state.animating) {
updateControls()
} else {
let i = 0
while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++
const p0 = activePath[i]
const p1 = activePath[i + 1] ?? p0
const t0 = activePath[Math.max(i - 1, 0)]
const t2 = activePath[Math.min(i + 2, activePath.length - 1)]
const dt = p1[2] - p0[2]
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
const x = p0[0] + (p1[0] - p0[0]) * t
const y = p0[1] + (p1[1] - p0[1]) * t
const dx = t2[0] - t0[0]
const dy = t2[1] - t0[1]
const rotation = Math.atan2(dy, dx) * 2
activeStonePos = { x, y, rotation, team: animationTeam }
}
}
renderer.draw({
stones: gameState.stones,
stones: model.state.stones,
broom:
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
? broom
model.state.phase === 'playing' &&
!model.state.animating &&
model.state.myTeam === model.state.turnTeam
? model.broom
: null,
animating: gameState.animating,
animating: model.state.animating,
activeStonePos,
})
requestAnimationFrame(render)
}
const callbacks: NetCallbacks = {
onJoined: (_roomId, team) => {
gameState.myTeam = team
onJoined: () => {
updateControls()
},
onWaiting: () => {
gameState.phase = 'waiting'
model.setWaiting()
updateControls()
},
onGameState: (msg) => {
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
if (reset) {
gameState.stones = []
pendingStones = []
}
if (gameState.animating) {
pendingStones = msg.stones
} else {
gameState.stones = msg.stones
}
gameState = {
...gameState,
end: msg.end,
scores: msg.scores,
hammer: msg.hammer,
turnTeam: msg.turn_team,
phase: msg.phase,
}
model.updateGameState(msg)
updateControls()
},
onTrajectory: (path) => {
activePath = trimPathToStartAtHogLine(path)
gameState.animating = activePath.length > 1
animationStartTime = performance.now()
animationTeam = gameState.turnTeam
pendingStones = []
onTrajectory: (paths) => {
model.startTrajectory(paths)
updateControls()
},
onEndScored: (end, points, scoringTeam) => {
@ -206,45 +123,45 @@ export function startGame(): void {
return { x: e.clientX, y: e.clientY }
}
const constrainBroom = (world: { x: number; y: number }) => {
const dx = world.x - HOUSE_CENTER.x
const dy = world.y - HOUSE_CENTER.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (dist > HOUSE_RADIUS) {
const ratio = HOUSE_RADIUS / dist
return {
x: HOUSE_CENTER.x + dx * ratio,
y: HOUSE_CENTER.y + dy * ratio,
}
}
return world
}
let lastPanScreenY = 0
const handleStart = (e: Event) => {
if (!isMyDragTurn()) return
isDragging = true
const pos = getPos(e as TouchEvent)
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
const world = renderer.screenToWorld(pos.x, pos.y)
if (model.isMyTurn) {
model.isDragging = true
model.broom = world
} else {
model.isPanning = true
lastPanScreenY = pos.y
}
}
const handleMove = (e: Event) => {
if (!isDragging || !isMyDragTurn()) return
if (model.isDragging && model.isMyTurn) {
e.preventDefault()
const pos = getPos(e as TouchEvent)
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
model.broom = renderer.screenToWorld(pos.x, pos.y)
return
}
if (model.isPanning) {
e.preventDefault()
const pos = getPos(e as TouchEvent)
const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY)
const nowWorld = renderer.screenToWorld(pos.x, pos.y)
// Direct-manipulation pan: dragging the sheet down reveals the hog line above.
const deltaY = nowWorld.y - prevWorld.y
const sensitivity = 1.5
renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity)
lastPanScreenY = pos.y
}
}
const handleEnd = () => {
isDragging = false
}
const isMyDragTurn = () => {
return (
gameState.myTeam !== null &&
gameState.turnTeam === gameState.myTeam &&
gameState.phase === 'playing' &&
!gameState.animating
)
model.isDragging = false
model.isPanning = false
}
canvas.addEventListener('touchstart', handleStart, { passive: false })
@ -256,10 +173,18 @@ export function startGame(): void {
canvas.addEventListener('mouseleave', handleEnd)
throwBtn.addEventListener('click', () => {
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
if (!model.isMyTurn) return
sendThrow(
hud.teamSelect.value as Team,
model.broom.x,
model.broom.y,
velocity.getWeight(),
curls.getSelected(),
friction.getFriction(),
)
})
connect(room, callbacks)
updateControls()
render()
}

View File

@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers'
export interface Hud {
root: HTMLDivElement
teamSelect: HTMLSelectElement
velocityControl: HTMLDivElement
curlSelector: HTMLDivElement
frictionControl: HTMLDivElement
throwButton: HTMLButtonElement
setTeam: (team: Team) => void
update: (state: {
phase: Phase
end: number
scores: number[]
hammer: Team
turnTeam: Team
myTeam: Team | null
animating: boolean
}) => void
showToast: (message: string) => void
@ -48,12 +49,20 @@ export function createHud(): Hud {
const root = document.createElement('div')
root.id = 'hud'
root.innerHTML = `
<div id="hud-top-group">
<div class="hud-row" id="share-row">
<div id="share"><button>Copy share link</button></div>
</div>
<div class="hud-row">
<div id="score">Red 0 - Yellow 0</div>
<div id="end-info">End 1 · Waiting</div>
<div id="team">You: -</div>
<select id="team-select" aria-label="Team">
<option value="red">Red</option>
<option value="yellow">Yellow</option>
</select>
<div id="hammer">Hammer: -</div>
</div>
</div>
<div class="hud-row" style="align-items:flex-end;">
<div id="velocity-control"></div>
<div id="curl-selector"></div>
@ -66,34 +75,32 @@ export function createHud(): Hud {
<button id="throw-btn" disabled>THROW</button>
</div>
</div>
<div id="waiting">Waiting for other player</div>
<div id="share"><button>Copy share link</button></div>
<div id="waiting">Waiting</div>
`
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
const teamEl = root.querySelector<HTMLDivElement>('#team')!
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
return {
root,
teamSelect,
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
setTeam: (team) => {
teamSelect.value = team
},
update: (state) => {
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
const waiting =
state.phase === 'waiting' ||
(state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) ||
state.animating
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting)
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
},
showToast: (message: string) => {
const toast = document.createElement('div')
@ -179,14 +186,15 @@ export function createCurlSelector(
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
const state = { selected: 1, enabled: true }
const options = [
{ value: -1, label: '↶ Left' },
{ value: 1, label: 'Right ↷' },
{ value: -1, label: '↷', ariaLabel: 'Left curl' },
{ value: 1, label: '↶', ariaLabel: 'Right curl' },
]
container.innerHTML = ''
for (const opt of options) {
const btn = document.createElement('button')
btn.className = 'curl-btn'
btn.textContent = opt.label
btn.ariaLabel = opt.ariaLabel
btn.dataset.curl = String(opt.value)
btn.addEventListener('click', () => {
if (!state.enabled) return

View File

@ -1,5 +1,6 @@
import type {
ServerGameStateMessage,
ServerStoneTrajectory,
ServerMessageTyped as ServerMessage,
Team,
} from './protocol'
@ -13,10 +14,10 @@ function resolveWsUrl(): string {
}
export interface NetCallbacks {
onJoined: (room: string, team: Team) => void
onJoined: (room: string) => void
onWaiting: (message: string) => void
onGameState: (msg: ServerGameStateMessage) => void
onTrajectory: (path: [number, number, number][]) => void
onTrajectory: (paths: ServerStoneTrajectory[]) => void
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
onGameOver: (scores: number[], winner: Team | null) => void
onError: (message: string) => void
@ -25,10 +26,9 @@ export interface NetCallbacks {
let socket: WebSocket | null = null
export function connect(room: string, team: Team | null, callbacks: NetCallbacks): void {
export function connect(room: string, callbacks: NetCallbacks): void {
if (socket) return
const teamParam = team ? `&team=${encodeURIComponent(team)}` : ''
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}`
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}`
const ws = new WebSocket(url)
ws.onopen = () => {
@ -42,7 +42,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
switch (msg.type) {
case 'joined':
callbacks.onJoined(msg.room, msg.team)
callbacks.onJoined(msg.room)
break
case 'waiting':
callbacks.onWaiting(msg.message)
@ -51,7 +51,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
callbacks.onGameState(msg)
break
case 'trajectory':
callbacks.onTrajectory(msg.path)
callbacks.onTrajectory(msg.paths)
break
case 'end_scored':
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
@ -76,7 +76,24 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
ws.onerror = () => {}
}
export function sendThrow(broomX: number, broomY: number, weight: number, curl: number, friction: number): void {
export function sendThrow(
team: Team,
broomX: number,
broomY: number,
weight: number,
curl: number,
friction: number,
): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: broomY, weight, curl, friction }))
socket.send(
JSON.stringify({
type: 'throw',
team,
broom_x: broomX,
broom_y: broomY,
weight,
curl,
friction,
}),
)
}

View File

@ -4,12 +4,13 @@ export const STONES_PER_TEAM = 8
export const ENDS = 10
export const SHEET_WIDTH = 5.0
export const SHEET_LENGTH = 45.0
export const FEET_TO_METERS = 0.3048
export const HOUSE_CENTER = { x: 0, y: 38.5 }
export const HOUSE_RADIUS = 1.83
export const BUTTON_RADIUS = 0.1524
export const FOUR_FT_RADIUS = 0.6096
export const EIGHT_FT_RADIUS = 1.2192
export const TWELVE_FT_RADIUS = 1.8288
export const HOUSE_RADIUS = 6 * FEET_TO_METERS
export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS
export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
export const HOG_LINE_Y = 21.0
export const BACK_LINE_Y = 42.0
export const HACK_Y = 2.0
@ -26,8 +27,16 @@ export interface StoneState {
active: boolean
}
export interface DrawableStone {
x: number
y: number
rotation: number
team: Team
}
export interface ClientThrowMessage {
type: 'throw'
team: Team
broom_x: number
broom_y: number
weight: number
@ -38,7 +47,6 @@ export interface ClientThrowMessage {
export interface ServerJoinedMessage {
type: 'joined'
room: string
team: Team
}
export interface ServerWaitingMessage {
@ -56,9 +64,14 @@ export interface ServerGameStateMessage {
phase: Phase
}
export interface ServerStoneTrajectory {
stone_id: number
path: [number, number, number][]
}
export interface ServerTrajectoryMessage {
type: 'trajectory'
path: [number, number, number][]
paths: ServerStoneTrajectory[]
}
export interface ServerEndScoredMessage {

View File

@ -9,8 +9,8 @@ import {
STONE_RADIUS,
TWELVE_FT_RADIUS,
HOUSE_CENTER,
type DrawableStone,
type StoneState,
type Team,
} from './protocol'
export interface Renderer {
@ -19,19 +19,31 @@ export interface Renderer {
setSize: () => void
worldToScreen: (x: number, y: number) => { x: number; y: number }
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
setViewYOffset: (y: number) => void
clampViewYOffset: () => number
draw: (state: {
stones: StoneState[]
broom: { x: number; y: number } | null
animating: boolean
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
activeStonePos: DrawableStone[]
}) => void
}
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
const ctx = canvas.getContext('2d')!
// Show the area from the hog line (top) to the back line (bottom).
const viewportHeight = BACK_LINE_Y - HOG_LINE_Y
const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 }
// Fit full sheet width (5 m) with a little side padding so both sidelines show.
// Scale is primarily width-based, but also capped so house + backline fit above the HUD.
const SIDE_PADDING_M = 0.25
const viewportWidth = SHEET_WIDTH + SIDE_PADDING_M * 2
// Min world height: top of 12ft ring through backline + small pad.
const MIN_VIEW_HEIGHT_M =
BACK_LINE_Y - (HOUSE_CENTER.y - TWELVE_FT_RADIUS) + SIDE_PADDING_M
// Throw button (80) + hud padding (12×2) + small gap above controls.
const BOTTOM_UI_PX = 120
const viewportCenter = { x: 0, y: HOUSE_CENTER.y }
// Prefer backline at the bottom of the usable ice area (just above HUD).
// First setSize()/setViewYOffset clamps this to the valid minOffset.
let viewportYOffset = -(BACK_LINE_Y - HOUSE_CENTER.y)
const setSize = () => {
const dpr = window.devicePixelRatio || 1
@ -42,33 +54,75 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
canvas.style.width = `${width}px`
canvas.style.height = `${height}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
// Re-clamp so default framing keeps the backline just above the HUD.
setViewYOffset(viewportYOffset)
}
/** Screen height available for ice (above bottom controls). */
const usableHeightPx = (): number => {
return Math.max(1, window.innerHeight - BOTTOM_UI_PX)
}
const scale = (): number => {
const height = window.innerHeight
return height / viewportHeight
const byWidth = window.innerWidth / viewportWidth
const byHeight = usableHeightPx() / MIN_VIEW_HEIGHT_M
// Width fit for sidelines; height cap so house/backline stay on-screen on wide displays.
return Math.min(byWidth, byHeight)
}
const viewportHeight = (): number => {
return usableHeightPx() / scale()
}
/** Vertical center of the usable ice area (not the full window). */
const viewCenterY = (): number => {
return usableHeightPx() / 2
}
const effectiveViewportCenter = () => ({
x: viewportCenter.x,
y: viewportCenter.y - viewportYOffset,
})
const worldToScreen = (x: number, y: number) => {
const s = scale()
const cx = window.innerWidth / 2
const cy = window.innerHeight / 2
const cy = viewCenterY()
const center = effectiveViewportCenter()
return {
x: cx + (x - viewportCenter.x) * s,
y: cy + (y - viewportCenter.y) * s,
x: cx + (x - center.x) * s,
y: cy + (y - center.y) * s,
}
}
const screenToWorld = (sx: number, sy: number) => {
const s = scale()
const cx = window.innerWidth / 2
const cy = window.innerHeight / 2
const cy = viewCenterY()
const center = effectiveViewportCenter()
return {
x: (sx - cx) / s + viewportCenter.x,
y: viewportCenter.y + (sy - cy) / s,
x: (sx - cx) / s + center.x,
y: center.y + (sy - cy) / s,
}
}
const clampViewYOffset = (): number => {
// Positive offset pans the view upward (toward the hog line).
// When the usable viewport is tall enough, minOffset pins BACK_LINE_Y to the
// bottom of the usable area (just above the HUD), not under the controls.
const halfHeight = viewportHeight() / 2
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
return Math.max(minOffset, Math.min(maxOffset, viewportYOffset))
}
const setViewYOffset = (y: number) => {
const halfHeight = viewportHeight() / 2
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
viewportYOffset = Math.max(minOffset, Math.min(maxOffset, y))
}
const drawLine = (
x1: number,
y1: number,
@ -102,7 +156,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
}
}
const drawStone = (stone: StoneState | { x: number; y: number; rotation: number; team: Team }) => {
const drawStone = (stone: DrawableStone) => {
const c = worldToScreen(stone.x, stone.y)
const r = STONE_RADIUS * scale()
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
stones: StoneState[]
broom: { x: number; y: number } | null
animating: boolean
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
activeStonePos: DrawableStone[]
}) => {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
@ -149,12 +203,14 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)')
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff')
if (state.animating) {
for (const stone of state.activeStonePos) {
drawStone(stone)
}
} else {
for (const stone of state.stones) {
drawStone(stone)
}
if (state.activeStonePos) {
drawStone(state.activeStonePos)
}
if (state.broom) {
@ -170,5 +226,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
setSize()
window.addEventListener('resize', setSize)
return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw }
return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw }
}

View File

@ -84,30 +84,57 @@ html, body {
z-index: 10;
}
#hud-top-group {
display: flex;
flex-direction: column;
gap: 4px;
}
#share {
position: absolute;
bottom: 12px;
left: 50%;
transform: translateX(-50%);
text-align: center;
display: flex;
justify-content: center;
width: 100%;
}
#share-row {
justify-content: center;
margin-bottom: 4px;
}
#share-row #share {
position: static;
transform: none;
}
#share button {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 14px;
padding: 6px 12px;
border-radius: 12px;
font-size: 13px;
font-size: 12px;
pointer-events: auto;
}
#team {
display: none;
}
#team-select {
font-size: 13px;
font-weight: 600;
padding: 4px 8px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.15);
color: white;
border: 1px solid rgba(255, 255, 255, 0.3);
pointer-events: auto;
min-width: 80px;
}
#team-select option {
background: #0b1f3a;
color: white;
}
#velocity-control {
@ -205,67 +232,6 @@ html, body {
text-align: center;
}
#team-picker {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.75);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
pointer-events: auto;
}
.team-picker-box {
background: #0b1f3a;
border: 1px solid rgba(255, 255, 255, 0.2);
border-radius: 16px;
padding: 24px;
text-align: center;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.team-picker-box h2 {
margin: 0 0 16px 0;
font-size: 18px;
}
.team-picker-buttons {
display: flex;
gap: 16px;
justify-content: center;
flex-wrap: wrap;
}
.team-btn {
width: 120px;
height: 120px;
border-radius: 16px;
border: 2px solid rgba(255, 255, 255, 0.4);
color: white;
font-weight: 700;
font-size: 16px;
cursor: pointer;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
}
.team-btn.team-red {
background: #cc3333;
}
.team-btn.team-red:hover {
background: #e04444;
}
.team-btn.team-yellow {
background: #ccaa00;
}
.team-btn.team-yellow:hover {
background: #e0be00;
}
#throw-btn {
width: 80px;
height: 80px;
@ -283,3 +249,53 @@ html, body {
background: #557766;
color: rgba(255, 255, 255, 0.5);
}
@media (max-width: 480px) {
#hud {
padding: 8px;
}
.hud-row {
gap: 6px;
}
#velocity-control {
min-width: 0;
flex: 1 1 auto;
padding: 2px;
}
.velocity-readout {
min-width: 0;
font-size: 10px;
}
.curl-btn {
min-width: 44px;
width: 44px;
height: 36px;
font-size: 18px;
border-color: rgba(255, 255, 255, 0.6);
background: rgba(0, 0, 0, 0.5);
}
#friction-control {
min-width: 0;
flex: 0 1 auto;
padding: 2px;
}
#friction-slider {
width: 60px;
}
#friction-value {
font-size: 10px;
}
#throw-btn {
width: 56px;
height: 56px;
font-size: 12px;
}
}