jasonlooked #1

Merged
eros merged 21 commits from jasonlooked into main 2026-07-11 10:13:09 -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>)>,
Review

don't specify stone color here, just team1 or team2, allow generalizability to choose colors. for now we will default to red and yellow

don't specify stone color here, just team1 or team2, allow generalizability to choose colors. for now we will default to red and yellow
Review

instead of a score. call it scoreboard. for each end, specify team with hammer, score for team1, score for team2. show this scoreboard on the frontend. this allows the hammer and turn_team variables to go away.

instead of a score. call it scoreboard. for each end, specify team with hammer, score for team1, score for team2. show this scoreboard on the frontend. this allows the hammer and turn_team variables to go away.
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,
}
}
Review

on the frontend, use a skeumorphic HUD to display how many stones are left. for instance, at the start of the game, show 2 rows of 8 stones. a stone should be removed from the hud when it is thrown.

on the frontend, use a skeumorphic HUD to display how many stones are left. for instance, at the start of the game, show 2 rows of 8 stones. a stone should be removed from the hud when it is thrown.
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')!
Review

move physics parameters (friction, curl) to a dropdown menu

move physics parameters (friction, curl) to a dropdown menu
@ -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'
Review

stone trajectory data structucture should be called trajectories with shape something like {stones: [{stoneid, rotation, team, trajectory:[x,y,theta]}])

same on the backend

stone trajectory data structucture should be called trajectories with shape something like {stones: [{stoneid, rotation, team, trajectory:[x,y,theta]}]) same on the backend
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;
}
}