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 ## 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). - **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI. - **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
@ -21,22 +21,24 @@ A 1v1 async multiplayer 2D curling game for mobile browser.
npm run dev npm run dev
# opens on 0.0.0.0:5173 by default # 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 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 ### 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 ### Controls
- Drag inside the house to place the broom (aim point). - When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house.
- Tap a weight `1``10`. - When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line).
- Tap **THROW**. - Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
- The server runs the physics and streams the trajectory; the frontend interpolates the animation. - 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 ### Architecture
@ -52,8 +54,12 @@ With the backend and frontend dev server running:
```bash ```bash
cd e2e cd e2e
npm install -g ws # or npm install ws locally in the project npm install -g ws # or npm install ws locally in the project
node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal node e2e_test.cjs # room lifecycle, throw, trajectory
node e2e_score.cjs # stones remain in play and alternate turns 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 ### Limitations / known simplifications

View File

@ -12,8 +12,6 @@ pub enum GamePhase {
} }
pub struct Game { pub struct Game {
red: Option<Player>,
yellow: Option<Player>,
phase: GamePhase, phase: GamePhase,
end: u8, end: u8,
scores: [i32; 2], scores: [i32; 2],
@ -24,14 +22,18 @@ pub struct Game {
stones_red: u8, stones_red: u8,
stones_yellow: u8, stones_yellow: u8,
last_end_scored: Option<(u8, i32, Option<Team>)>, 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 { impl Game {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
red: None,
yellow: None,
phase: GamePhase::Waiting, phase: GamePhase::Waiting,
end: 1, end: 1,
scores: [0, 0], scores: [0, 0],
@ -42,81 +44,10 @@ impl Game {
stones_red: STONES_PER_TEAM, stones_red: STONES_PER_TEAM,
stones_yellow: STONES_PER_TEAM, stones_yellow: STONES_PER_TEAM,
last_end_scored: None, 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) { pub fn start(&mut self) {
if !self.can_start() {
return;
}
self.hammer = if rand::random() { Team::Red } else { Team::Yellow }; self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
self.turn_team = self.hammer.other(); self.turn_team = self.hammer.other();
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
@ -129,37 +60,24 @@ impl Game {
self.active_stones.clear(); self.active_stones.clear();
} }
pub fn current_player_id(&self) -> Option<&str> { pub fn handle_throw(
let p = match self.turn_team { &mut self,
Team::Red => self.red.as_ref()?, team: Team,
Team::Yellow => self.yellow.as_ref()?, broom_x: f32,
}; broom_y: f32,
Some(&p.id) weight: u8,
} curl: i8,
friction: f32,
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> { ) -> Result<Vec<StoneTrajectory>, String> {
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } } if self.turn_team != team {
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 {
return Err("Not your turn".to_string()); return Err("Not your turn".to_string());
} }
if self.phase != GamePhase::Playing { if self.phase != GamePhase::Playing {
return Err("Cannot throw now".to_string()); 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(); 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.active_stones = self.physics.current_stones();
self.phase = GamePhase::Simulating; self.phase = GamePhase::Simulating;
@ -168,7 +86,35 @@ impl Game {
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1), 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,
Outdated
Review

the game should be playable with a single player who has the capability to switch teams

the game should be playable with a single player who has the capability to switch teams
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) { pub fn finish_simulation(&mut self) {
@ -179,10 +125,6 @@ impl Game {
self.score_end_internal(false); self.score_end_internal(false);
} }
pub fn score_end(&mut self) {
self.score_end_internal(true);
}
fn score_end_internal(&mut self, force: bool) { fn score_end_internal(&mut self, force: bool) {
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0); let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
if !end_done { if !end_done {
@ -220,7 +162,7 @@ impl Game {
Team::Red => 0, Team::Red => 0,
Team::Yellow => 1, Team::Yellow => 1,
}; };
self.scores[team_idx] += points as i32; self.scores[team_idx] += points;
self.hammer = team.other(); self.hammer = team.other();
} else { } else {
points = 0; points = 0;
@ -229,10 +171,10 @@ impl Game {
self.last_end_scored = Some((self.end, points, scoring_team)); self.last_end_scored = Some((self.end, points, scoring_team));
self.phase = GamePhase::EndComplete; 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 tied = self.scores[0] == self.scores[1];
let after_regulation = self.end >= ENDS; let after_regulation = self.end >= ENDS;
@ -241,10 +183,6 @@ impl Game {
return; return;
} }
if after_regulation && tied {
// Extra end
}
self.end += 1; self.end += 1;
self.stones_red = STONES_PER_TEAM; self.stones_red = STONES_PER_TEAM;
self.stones_yellow = STONES_PER_TEAM; self.stones_yellow = STONES_PER_TEAM;
@ -255,10 +193,6 @@ impl Game {
self.phase = GamePhase::Playing; self.phase = GamePhase::Playing;
} }
pub fn phase(&self) -> GamePhase {
self.phase
}
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> { pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
let msg = self.last_end_scored.map(|(end, points, scoring_team)| { let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
ServerMessage::EndScored { end, points, scoring_team } ServerMessage::EndScored { end, points, scoring_team }
@ -300,24 +234,18 @@ impl Game {
} }
} }
pub type RoomId = String;
pub struct Room { pub struct Room {
pub id: RoomId,
pub game: Game, pub game: Game,
pub tx: tokio::sync::broadcast::Sender<ServerMessage>, pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
} }
impl Room { impl Room {
pub fn new(id: &str) -> Self { pub fn new(_id: &str) -> Self {
let (tx, _) = tokio::sync::broadcast::channel(256); let (tx, _) = tokio::sync::broadcast::channel(256);
let mut room = Self { Self {
id: id.to_string(),
game: Game::new(), game: Game::new(),
tx, tx,
}; }
room.game.room_tx = Some(room.tx.clone());
room
} }
} }
@ -326,29 +254,46 @@ mod tests {
use super::*; use super::*;
#[test] #[test]
fn prefers_red_when_requested_and_free() { fn starts_in_waiting_phase() {
let mut game = Game::new(); let game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red)); assert!(matches!(game.phase, GamePhase::Waiting));
} }
#[test] #[test]
fn prefers_yellow_when_requested_and_free() { fn starts_when_called() {
let mut game = Game::new(); 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] #[test]
fn falls_back_when_preferred_taken() { fn rejects_throw_for_wrong_team() {
let mut game = Game::new(); let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red)); game.start();
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow)); 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] #[test]
fn legacy_order_without_preference() { fn accepts_throw_for_turn_team() {
let mut game = Game::new(); let mut game = Game::new();
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red)); game.start();
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow)); let turn = game.turn_team;
assert_eq!(game.add_player("p3".into(), None), None); 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 futures_util::{sink::SinkExt, stream::StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::game::{GamePhase, Room}; use crate::game::{Room, ThrowOutcome};
use crate::protocol::*; use crate::protocol::*;
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>, rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
} }
impl AppState { impl AppState {
fn new() -> Self { 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>> { async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
let mut rooms = self.rooms.lock().unwrap(); let mut rooms = self.rooms.lock().await;
if let Some(room) = rooms.get(room_id).cloned() { if let Some(room) = rooms.get(room_id).cloned() {
return room; 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()); rooms.insert(room_id.to_string(), room.clone());
room room
} }
@ -45,7 +50,6 @@ impl AppState {
#[derive(Deserialize)] #[derive(Deserialize)]
struct RoomQuery { struct RoomQuery {
room: String, room: String,
team: Option<Team>,
} }
#[derive(Serialize)] #[derive(Serialize)]
@ -81,7 +85,7 @@ async fn health() -> impl IntoResponse {
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse { async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
let room_id = generate_room_code(); 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 })) (StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
} }
@ -90,53 +94,50 @@ async fn ws_handler(
Query(query): Query<RoomQuery>, Query(query): Query<RoomQuery>,
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> impl IntoResponse { ) -> 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>) { async fn handle_socket(
let room_arc = state.get_or_create_room(&room_id); socket: WebSocket,
let player_id = Uuid::new_v4().to_string(); state: Arc<AppState>,
let player_id_for_recv = player_id.clone(); 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; let mut room_guard = room.lock().await;
if room.game.can_start() { if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) {
let err = serde_json::to_string(&ServerMessage::Error { room_guard.game.start();
message: "Room is full".to_string(), }
Outdated
Review

remove this, there should not be a way to have a full room. all anyone to join and choose a team. teams may have multiple clients

remove this, there should not be a way to have a full room. all anyone to join and choose a team. teams may have multiple clients
}).unwrap(); }
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
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; 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. fn spawn_forwarder(
let team = { mut sender: WsSender,
let mut room = room_arc.lock().await; mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
let team = room.game.add_player(player_id.clone(), preferred_team) ) -> tokio::task::JoinHandle<()> {
.unwrap_or(Team::Red); tokio::spawn(async move {
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 {
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(msg) => { Ok(msg) => {
@ -151,49 +152,51 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
Err(_) => break, 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(); async fn broadcast_room_state(
let recv_id = player_id_for_recv; room: &Arc<Mutex<Room>>,
let recv_task = tokio::spawn(async move { 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 { 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 text_ref = text.as_str();
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref); let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
match parsed { match parsed {
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => { Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
let room = recv_room.clone(); let mut room_guard = room.lock().await;
let mut room = room.lock().await; match room_guard
if room.game.current_team_for_player(&recv_id) != Some(team) { .game
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() }); .process_throw(team, broom_x, broom_y, weight, curl, friction)
continue; {
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) { let _ = tx.send(state_message);
Ok(path) => { if let Some(over) = game_over {
room.tx.send(ServerMessage::Trajectory { path }).ok(); let _ = tx.send(over);
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();
} }
} }
Err(e) => { 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, weight: u8,
curl: i8, curl: i8,
friction: f32, friction: f32,
) -> Result<Vec<(f32, f32, f32)>, String> { ) -> Result<Vec<StoneTrajectory>, String> {
let weight = weight.clamp(1, 10) as f32; let weight = weight.clamp(1, 10) as f32;
let t = (weight - 1.0) / 9.0; let t = (weight - 1.0) / 9.0;
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED); let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
@ -132,7 +132,7 @@ impl PhysicsWorld {
vy: f32, vy: f32,
curl_sign: i8, curl_sign: i8,
damping_mult: f32, damping_mult: f32,
) -> Result<Vec<(f32, f32, f32)>, String> { ) -> Result<Vec<StoneTrajectory>, String> {
let id = self.next_stone_id; let id = self.next_stone_id;
self.next_stone_id += 1; self.next_stone_id += 1;
@ -161,15 +161,31 @@ impl PhysicsWorld {
self.simulate_until_rest(id) self.simulate_until_rest(id)
} }
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> { fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
let mut path: Vec<(f32, f32, f32)> = Vec::new(); // 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 sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
let mut sample_accum: f32 = 0.0; let mut sample_accum: f32 = 0.0;
let mut time: f32 = 0.0; let mut time: f32 = 0.0;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { // Pre-allocate a path buffer for every stone currently in the world.
let body = &self.bodies[*h]; let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
path.push((body.translation().x, body.translation().y, time)); .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 { loop {
@ -180,9 +196,11 @@ impl PhysicsWorld {
if sample_accum >= sample_step { if sample_accum >= sample_step {
sample_accum -= sample_step; sample_accum -= sample_step;
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) { for (_, handle, path) in &mut paths {
let body = &self.bodies[*h]; if let Some(body) = self.bodies.get(*handle) {
path.push((body.translation().x, body.translation().y, time)); let pos = body.translation();
path.push((pos.x, pos.y, time));
}
} }
} }
@ -193,7 +211,27 @@ impl PhysicsWorld {
self.prune_out_of_play(); 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. // Rotate each stone's velocity slightly based on its selected curl direction.
@ -377,4 +415,70 @@ mod tests {
let stones = world.current_stones(); let stones = world.current_stones();
assert!(stones.is_empty(), "stones short of the hog line should be pruned"); 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 TICK_RATE_HZ: u16 = 120;
pub const SAMPLE_RATE_HZ: u16 = 40; pub const SAMPLE_RATE_HZ: u16 = 40;
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32; 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 STONES_PER_TEAM: u8 = 8;
pub const ENDS: u8 = 10; pub const ENDS: u8 = 10;
@ -12,11 +13,7 @@ pub const ENDS: u8 = 10;
pub const SHEET_WIDTH: f32 = 5.0; pub const SHEET_WIDTH: f32 = 5.0;
pub const SHEET_LENGTH: f32 = 45.0; pub const SHEET_LENGTH: f32 = 45.0;
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5); pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
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 HOG_LINE_Y: f32 = 21.0; pub const HOG_LINE_Y: f32 = 21.0;
pub const BACK_LINE_Y: f32 = 42.0; pub const BACK_LINE_Y: f32 = 42.0;
pub const HACK_Y: f32 = 2.0; pub const HACK_Y: f32 = 2.0;
@ -59,6 +56,7 @@ impl fmt::Display for Team {
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ClientMessage { pub enum ClientMessage {
Throw { Throw {
team: Team,
broom_x: f32, broom_x: f32,
broom_y: f32, broom_y: f32,
weight: u8, weight: u8,
@ -75,7 +73,7 @@ fn default_friction() -> f32 { 1.0 }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")] #[serde(tag = "type", rename_all = "snake_case")]
pub enum ServerMessage { pub enum ServerMessage {
Joined { room: String, team: Team }, Joined { room: String },
Waiting { message: String }, Waiting { message: String },
GameState { GameState {
end: u8, end: u8,
@ -86,7 +84,7 @@ pub enum ServerMessage {
phase: Phase, phase: Phase,
}, },
Trajectory { Trajectory {
path: Vec<(f32, f32, f32)>, paths: Vec<StoneTrajectory>,
}, },
EndScored { end: u8, points: i32, scoring_team: Option<Team> }, EndScored { end: u8, points: i32, scoring_team: Option<Team> },
GameOver { GameOver {
@ -108,6 +106,12 @@ pub enum Phase {
GameComplete, GameComplete,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneTrajectory {
pub stone_id: u32,
pub path: Vec<(f32, f32, f32)>,
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StoneState { pub struct StoneState {
pub id: u32, pub id: u32,
@ -117,10 +121,3 @@ pub struct StoneState {
pub rotation: f32, pub rotation: f32,
pub active: bool, 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) => { ws.on('message', (data) => {
const msg = JSON.parse(data.toString()) const msg = JSON.parse(data.toString())
messages.push(msg) messages.push(msg)
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) if (msg.type === 'joined') resolve({ ws, messages })
}) })
ws.on('error', reject) ws.on('error', reject)
}) })
@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) {
}, 5000) }, 5000)
const turn = getTurn() const turn = getTurn()
if (!turn) throw new Error('no turn') if (!turn) throw new Error('no turn')
const thrower = turn === p1.team ? p1 : p2
const broomX = (Math.random() - 0.5) * 0.6 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 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) 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) messages.push(msg)
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '') console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
if (msg.type === 'joined') { if (msg.type === 'joined') {
resolve({ ws, messages, team: msg.team }) resolve({ ws, messages })
} }
}) })
ws.on('open', () => console.log(`[${name}] open`)) ws.on('open', () => console.log(`[${name}] open`))
@ -38,27 +38,23 @@ function latestState(messages) {
;(async () => { ;(async () => {
const p1 = await connect('p1') const p1 = await connect('p1')
await waitFor(() => p1.messages.some(m => m.type === 'waiting')) await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
const p2 = await connect('p2')
await waitFor(() => p2.messages.some(m => m.type === 'game_state'))
// Determine the current player and throw two stones in the same end. // Determine the current player and throw two stones in the same end.
let state = latestState(p1.messages) let state = latestState(p1.messages)
console.log('Initial state', state) console.log('Initial state', state)
// First throw: yellow aims slightly left of center. // First throw: current turn team.
let turn = state.turn_team let turn = state.turn_team
let turnPlayer = turn === p1.team ? p1 : p2 p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000) await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
state = latestState(p1.messages) state = latestState(p1.messages)
console.log('After first throw:', state) console.log('After first throw:', state)
// Second throw: red aims slightly right of center. // Second throw: other team.
turn = state.turn_team turn = state.turn_team
turnPlayer = turn === p1.team ? p1 : p2 p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000) await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
state = latestState(p1.messages) state = latestState(p1.messages)
@ -77,7 +73,6 @@ function latestState(messages) {
console.log('PERSISTENCE E2E PASSED') console.log('PERSISTENCE E2E PASSED')
p1.ws.close() p1.ws.close()
p2.ws.close()
process.exit(0) process.exit(0)
})().catch(err => { })().catch(err => {
console.error(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) => { ws.on('message', (data) => {
const msg = JSON.parse(data.toString()) const msg = JSON.parse(data.toString())
messages.push(msg) messages.push(msg)
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team }) if (msg.type === 'joined') resolve({ ws, messages })
}) })
ws.on('error', reject) ws.on('error', reject)
}) })
@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) {
;(async () => { ;(async () => {
const p1 = await connect('p1') const p1 = await connect('p1')
const p2 = await connect('p2')
await waitFor(() => p1.messages.some(m => m.type === 'game_state')) await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
let state = p1.messages.find(m => m.type === 'game_state') let state = p1.messages.find(m => m.type === 'game_state')
console.log('start turn', state.turn_team, 'hammer', state.hammer) console.log('start turn', state.turn_team, 'hammer', state.hammer)
const thrower1 = state.turn_team === p1.team ? p1 : p2 const thrower1 = state.turn_team
thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 })) 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.some(m => m.type === 'trajectory'), 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
state = p1.messages.slice(-1)[0] state = p1.messages.slice(-1)[0]
console.log('After first throw:', state) console.log('After first throw:', state)
const thrower2 = state.turn_team === p1.team ? p1 : p2 const thrower2 = state.turn_team
thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 })) 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.filter(m => m.type === 'trajectory').length >= 2, 15000)
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000) await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
await new Promise(r => setTimeout(r, 500)) await new Promise(r => setTimeout(r, 500))
console.log('Final stones', p1.messages.slice(-1)[0].stones) console.log('Final stones', p1.messages.slice(-1)[0].stones)
p1.ws.close() p1.ws.close()
p2.ws.close()
process.exit(0) process.exit(0)
})().catch(e => { })().catch(e => {
console.error(e) console.error(e)

View File

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

again, all stones should be animating at the synced time step

again, all stones should be animating at the synced time step
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)
}
}
Outdated
Review

the rotation (theta) should come from the physics engine

the rotation (theta) should come from the physics engine
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 { connect, sendThrow, type NetCallbacks } from './net'
import { createRenderer } from './renderer' import { createRenderer } from './renderer'
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol' import { type Team } from './protocol'
import { trimPathToStartAtHogLine } from './game-helpers' import { GameModel } from './game-model'
interface GameState {
end: number
scores: number[]
hammer: Team
turnTeam: Team
myTeam: Team | null
phase: Phase
stones: StoneState[]
animating: boolean
}
export function startGame(): void { export function startGame(): void {
const app = document.querySelector<HTMLDivElement>('#app')! 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() const hud = createHud()
app.appendChild(hud.root) app.appendChild(hud.root)
if (import.meta.env.DEV) {
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
}
const velocityContainer = hud.velocityControl const velocityContainer = hud.velocityControl
const curlContainer = hud.curlSelector const curlContainer = hud.curlSelector
const frictionContainer = hud.frictionControl const frictionContainer = hud.frictionControl
@ -42,141 +35,65 @@ export function startGame(): void {
const shareLink = `${window.location.origin}/?room=${room}` const shareLink = `${window.location.origin}/?room=${room}`
hud.setShareLink(shareLink) hud.setShareLink(shareLink)
const picker = document.createElement('div') const model = new GameModel()
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)
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => { const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
btn.addEventListener('click', () => { model.setMyTeam(initialTeam)
const team = btn.dataset.team as Team hud.setTeam(initialTeam)
picker.remove()
connect(room, team, callbacks)
})
})
let gameState: GameState = { // connect() is invoked after callbacks is defined below.
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'
const updateControls = () => { const updateControls = () => {
const myTurn = Boolean( const activeTeam = hud.teamSelect.value as Team
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing', model.setMyTeam(activeTeam)
) const myTurn = model.isMyTurn
velocity.setEnabled(myTurn && !gameState.animating) velocity.setEnabled(myTurn)
curls.setEnabled(myTurn && !gameState.animating) curls.setEnabled(myTurn)
friction.setEnabled(myTurn && !gameState.animating) friction.setEnabled(myTurn)
throwBtn.disabled = !myTurn || gameState.animating throwBtn.disabled = !myTurn
if (gameState.phase === 'simulating' || gameState.animating) { hud.update(model.state)
velocity.setEnabled(false)
curls.setEnabled(false)
friction.setEnabled(false)
}
hud.update(gameState)
} }
const render = () => { hud.teamSelect.addEventListener('change', () => {
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null localStorage.setItem('curltastic-team', hud.teamSelect.value)
if (gameState.animating && activePath.length > 1) { updateControls()
const elapsed = (performance.now() - animationStartTime) / 1000 })
const total = activePath[activePath.length - 1][2]
if (elapsed >= total) { const render = () => {
gameState.animating = false const wasAnimating = model.state.animating
if (pendingStones.length > 0) { const activeStonePos = model.tick(performance.now())
gameState.stones = pendingStones if (wasAnimating && !model.state.animating) {
pendingStones = []
}
updateControls() 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({ renderer.draw({
stones: gameState.stones, stones: model.state.stones,
broom: broom:
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam model.state.phase === 'playing' &&
? broom !model.state.animating &&
model.state.myTeam === model.state.turnTeam
? model.broom
: null, : null,
animating: gameState.animating, animating: model.state.animating,
activeStonePos, activeStonePos,
}) })
requestAnimationFrame(render) requestAnimationFrame(render)
} }
const callbacks: NetCallbacks = { const callbacks: NetCallbacks = {
onJoined: (_roomId, team) => { onJoined: () => {
gameState.myTeam = team
updateControls() updateControls()
}, },
onWaiting: () => { onWaiting: () => {
gameState.phase = 'waiting' model.setWaiting()
updateControls() updateControls()
}, },
onGameState: (msg) => { onGameState: (msg) => {
const reset = msg.phase === 'waiting' || msg.end !== gameState.end model.updateGameState(msg)
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,
}
updateControls() updateControls()
}, },
onTrajectory: (path) => { onTrajectory: (paths) => {
activePath = trimPathToStartAtHogLine(path) model.startTrajectory(paths)
gameState.animating = activePath.length > 1
animationStartTime = performance.now()
animationTeam = gameState.turnTeam
pendingStones = []
updateControls() updateControls()
}, },
onEndScored: (end, points, scoringTeam) => { onEndScored: (end, points, scoringTeam) => {
@ -206,45 +123,45 @@ export function startGame(): void {
return { x: e.clientX, y: e.clientY } return { x: e.clientX, y: e.clientY }
} }
const constrainBroom = (world: { x: number; y: number }) => { let lastPanScreenY = 0
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
}
const handleStart = (e: Event) => { const handleStart = (e: Event) => {
if (!isMyDragTurn()) return
isDragging = true
const pos = getPos(e as TouchEvent) 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) => { const handleMove = (e: Event) => {
if (!isDragging || !isMyDragTurn()) return if (model.isDragging && model.isMyTurn) {
e.preventDefault() e.preventDefault()
const pos = getPos(e as TouchEvent) 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 = () => { const handleEnd = () => {
isDragging = false model.isDragging = false
} model.isPanning = false
const isMyDragTurn = () => {
return (
gameState.myTeam !== null &&
gameState.turnTeam === gameState.myTeam &&
gameState.phase === 'playing' &&
!gameState.animating
)
} }
canvas.addEventListener('touchstart', handleStart, { passive: false }) canvas.addEventListener('touchstart', handleStart, { passive: false })
@ -256,10 +173,18 @@ export function startGame(): void {
canvas.addEventListener('mouseleave', handleEnd) canvas.addEventListener('mouseleave', handleEnd)
Outdated
Review

physics parameters (curl, friction). should be a shared game state.

physics parameters (curl, friction). should be a shared game state.
throwBtn.addEventListener('click', () => { throwBtn.addEventListener('click', () => {
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return if (!model.isMyTurn) return
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction()) sendThrow(
hud.teamSelect.value as Team,
model.broom.x,
model.broom.y,
velocity.getWeight(),
curls.getSelected(),
friction.getFriction(),
)
}) })
connect(room, callbacks)
updateControls() updateControls()
render() render()
} }

View File

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

View File

@ -1,5 +1,6 @@
import type { import type {
ServerGameStateMessage, ServerGameStateMessage,
ServerStoneTrajectory,
ServerMessageTyped as ServerMessage, ServerMessageTyped as ServerMessage,
Team, Team,
} from './protocol' } from './protocol'
@ -13,10 +14,10 @@ function resolveWsUrl(): string {
} }
export interface NetCallbacks { export interface NetCallbacks {
onJoined: (room: string, team: Team) => void onJoined: (room: string) => void
onWaiting: (message: string) => void onWaiting: (message: string) => void
onGameState: (msg: ServerGameStateMessage) => void onGameState: (msg: ServerGameStateMessage) => void
onTrajectory: (path: [number, number, number][]) => void onTrajectory: (paths: ServerStoneTrajectory[]) => void
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
onGameOver: (scores: number[], winner: Team | null) => void onGameOver: (scores: number[], winner: Team | null) => void
onError: (message: string) => void onError: (message: string) => void
@ -25,10 +26,9 @@ export interface NetCallbacks {
let socket: WebSocket | null = null 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 if (socket) return
const teamParam = team ? `&team=${encodeURIComponent(team)}` : '' const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}`
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}`
const ws = new WebSocket(url) const ws = new WebSocket(url)
ws.onopen = () => { ws.onopen = () => {
@ -42,7 +42,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
switch (msg.type) { switch (msg.type) {
case 'joined': case 'joined':
callbacks.onJoined(msg.room, msg.team) callbacks.onJoined(msg.room)
break break
case 'waiting': case 'waiting':
callbacks.onWaiting(msg.message) callbacks.onWaiting(msg.message)
@ -51,7 +51,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
callbacks.onGameState(msg) callbacks.onGameState(msg)
break break
case 'trajectory': case 'trajectory':
callbacks.onTrajectory(msg.path) callbacks.onTrajectory(msg.paths)
break break
case 'end_scored': case 'end_scored':
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null) 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 = () => {} 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 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 ENDS = 10
export const SHEET_WIDTH = 5.0 export const SHEET_WIDTH = 5.0
export const SHEET_LENGTH = 45.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_CENTER = { x: 0, y: 38.5 }
export const HOUSE_RADIUS = 1.83 export const HOUSE_RADIUS = 6 * FEET_TO_METERS
export const BUTTON_RADIUS = 0.1524 export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS
export const FOUR_FT_RADIUS = 0.6096 export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
export const EIGHT_FT_RADIUS = 1.2192 export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
export const TWELVE_FT_RADIUS = 1.8288 export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
export const HOG_LINE_Y = 21.0 export const HOG_LINE_Y = 21.0
export const BACK_LINE_Y = 42.0 export const BACK_LINE_Y = 42.0
export const HACK_Y = 2.0 export const HACK_Y = 2.0
@ -26,8 +27,16 @@ export interface StoneState {
active: boolean active: boolean
} }
export interface DrawableStone {
x: number
y: number
rotation: number
team: Team
}
export interface ClientThrowMessage { export interface ClientThrowMessage {
type: 'throw' 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_x: number
broom_y: number broom_y: number
weight: number weight: number
@ -38,7 +47,6 @@ export interface ClientThrowMessage {
export interface ServerJoinedMessage { export interface ServerJoinedMessage {
type: 'joined' type: 'joined'
room: string room: string
team: Team
} }
export interface ServerWaitingMessage { export interface ServerWaitingMessage {
@ -56,9 +64,14 @@ export interface ServerGameStateMessage {
phase: Phase phase: Phase
} }
export interface ServerStoneTrajectory {
stone_id: number
path: [number, number, number][]
}
export interface ServerTrajectoryMessage { export interface ServerTrajectoryMessage {
type: 'trajectory' type: 'trajectory'
path: [number, number, number][] paths: ServerStoneTrajectory[]
} }
export interface ServerEndScoredMessage { export interface ServerEndScoredMessage {

View File

@ -9,8 +9,8 @@ import {
STONE_RADIUS, STONE_RADIUS,
TWELVE_FT_RADIUS, TWELVE_FT_RADIUS,
HOUSE_CENTER, HOUSE_CENTER,
type DrawableStone,
type StoneState, type StoneState,
type Team,
} from './protocol' } from './protocol'
export interface Renderer { export interface Renderer {
@ -19,19 +19,31 @@ export interface Renderer {
setSize: () => void setSize: () => void
worldToScreen: (x: number, y: number) => { x: number; y: number } worldToScreen: (x: number, y: number) => { x: number; y: number }
screenToWorld: (sx: number, sy: number) => { x: number; y: number } screenToWorld: (sx: number, sy: number) => { x: number; y: number }
setViewYOffset: (y: number) => void
clampViewYOffset: () => number
draw: (state: { draw: (state: {
stones: StoneState[] stones: StoneState[]
broom: { x: number; y: number } | null broom: { x: number; y: number } | null
animating: boolean animating: boolean
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null activeStonePos: DrawableStone[]
}) => void }) => void
} }
export function createRenderer(canvas: HTMLCanvasElement): Renderer { export function createRenderer(canvas: HTMLCanvasElement): Renderer {
const ctx = canvas.getContext('2d')! const ctx = canvas.getContext('2d')!
// Show the area from the hog line (top) to the back line (bottom). // Fit full sheet width (5 m) with a little side padding so both sidelines show.
const viewportHeight = BACK_LINE_Y - HOG_LINE_Y // Scale is primarily width-based, but also capped so house + backline fit above the HUD.
const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 } 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 setSize = () => {
const dpr = window.devicePixelRatio || 1 const dpr = window.devicePixelRatio || 1
@ -42,33 +54,75 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
canvas.style.width = `${width}px` canvas.style.width = `${width}px`
canvas.style.height = `${height}px` canvas.style.height = `${height}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) 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 scale = (): number => {
const height = window.innerHeight const byWidth = window.innerWidth / viewportWidth
return height / viewportHeight 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 worldToScreen = (x: number, y: number) => {
const s = scale() const s = scale()
const cx = window.innerWidth / 2 const cx = window.innerWidth / 2
const cy = window.innerHeight / 2 const cy = viewCenterY()
const center = effectiveViewportCenter()
return { return {
x: cx + (x - viewportCenter.x) * s, x: cx + (x - center.x) * s,
y: cy + (y - viewportCenter.y) * s, y: cy + (y - center.y) * s,
} }
} }
const screenToWorld = (sx: number, sy: number) => { const screenToWorld = (sx: number, sy: number) => {
const s = scale() const s = scale()
const cx = window.innerWidth / 2 const cx = window.innerWidth / 2
const cy = window.innerHeight / 2 const cy = viewCenterY()
const center = effectiveViewportCenter()
return { return {
x: (sx - cx) / s + viewportCenter.x, x: (sx - cx) / s + center.x,
y: viewportCenter.y + (sy - cy) / s, 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 = ( const drawLine = (
x1: number, x1: number,
y1: 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 c = worldToScreen(stone.x, stone.y)
const r = STONE_RADIUS * scale() const r = STONE_RADIUS * scale()
const color = stone.team === 'red' ? '#d93025' : '#f9ab00' const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
stones: StoneState[] stones: StoneState[]
broom: { x: number; y: number } | null broom: { x: number; y: number } | null
animating: boolean animating: boolean
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null activeStonePos: DrawableStone[]
}) => { }) => {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) 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, 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') 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) { for (const stone of state.stones) {
drawStone(stone) drawStone(stone)
} }
if (state.activeStonePos) {
drawStone(state.activeStonePos)
} }
if (state.broom) { if (state.broom) {
@ -170,5 +226,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
setSize() setSize()
window.addEventListener('resize', 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; z-index: 10;
} }
#hud-top-group {
display: flex;
flex-direction: column;
gap: 4px;
}
#share { #share {
position: absolute; display: flex;
bottom: 12px; justify-content: center;
left: 50%; width: 100%;
transform: translateX(-50%); }
text-align: center;
#share-row {
justify-content: center;
margin-bottom: 4px;
}
#share-row #share {
position: static;
transform: none;
} }
#share button { #share button {
background: rgba(255, 255, 255, 0.15); background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.3); border: 1px solid rgba(255, 255, 255, 0.3);
color: white; color: white;
padding: 8px 14px; padding: 6px 12px;
border-radius: 12px; border-radius: 12px;
font-size: 13px; font-size: 12px;
pointer-events: auto; pointer-events: auto;
} }
#team { #team {
display: none;
}
#team-select {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
padding: 4px 8px; padding: 4px 8px;
border-radius: 10px; 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 { #velocity-control {
@ -205,67 +232,6 @@ html, body {
text-align: center; 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 { #throw-btn {
width: 80px; width: 80px;
height: 80px; height: 80px;
@ -283,3 +249,53 @@ html, body {
background: #557766; background: #557766;
color: rgba(255, 255, 255, 0.5); 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;
}
}