Compare commits

...

16 Commits

Author SHA1 Message Date
Jason Dekarske
473e18a751 docs: multi-client free team, free broom, multi-path e2e list
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-07-10 23:16:06 -07:00
20 changed files with 847 additions and 447 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],
@ -27,7 +25,7 @@ pub struct Game {
}
pub struct ThrowOutcome {
pub trajectory: Vec<(f32, f32, f32)>,
pub trajectory: Vec<StoneTrajectory>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>,
@ -36,8 +34,6 @@ pub struct ThrowOutcome {
impl Game {
pub fn new() -> Self {
Self {
red: None,
yellow: None,
phase: GamePhase::Waiting,
end: 1,
scores: [0, 0],
@ -51,57 +47,7 @@ impl Game {
}
}
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
let team = preferred
.filter(|t| self.slot_for(t).is_none())
.or_else(|| self.first_open_team())?;
let player = Player { id, team, connected: true };
*self.slot_for(&team) = Some(player);
Some(team)
}
fn slot_for(&mut self, team: &Team) -> &mut Option<Player> {
match team {
Team::Red => &mut self.red,
Team::Yellow => &mut self.yellow,
}
}
fn first_open_team(&self) -> Option<Team> {
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} 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 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;
@ -114,31 +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 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;
@ -147,19 +86,19 @@ impl Game {
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
}
Ok(path)
Ok(trajectory)
}
pub fn process_throw(
&mut self,
player_id: &str,
team: Team,
broom_x: f32,
broom_y: f32,
weight: u8,
curl: i8,
friction: f32,
) -> Result<ThrowOutcome, String> {
let trajectory = self.handle_throw(player_id, broom_x, broom_y, weight, curl, friction)?;
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
self.finish_simulation();
let end_scored = self.take_last_end_scored();
@ -223,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;
@ -315,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

@ -50,7 +50,6 @@ impl AppState {
#[derive(Deserialize)]
struct RoomQuery {
room: String,
team: Option<Team>,
}
#[derive(Serialize)]
@ -95,74 +94,43 @@ 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 = state.get_or_create_room(&room_id).await;
let (mut sender, receiver) = socket.split();
if try_join_room(&room).await.is_err() {
let err = serde_json::to_string(&ServerMessage::Error {
message: "Room is full".to_string(),
})
.unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
return;
{
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 player_id = Uuid::new_v4().to_string();
let team = register_player(&room, &player_id, preferred_team).await;
let joined = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(),
team,
})
.unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await;
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(), player_id.clone(), tx, receiver);
let recv_task = spawn_message_handler(room.clone(), tx, receiver);
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
remove_player(&room, &player_id).await;
}
async fn try_join_room(room: &Arc<Mutex<Room>>) -> Result<(), ()> {
let room_guard = room.lock().await;
if room_guard.game.can_start() {
return Err(());
}
Ok(())
}
async fn register_player(
room: &Arc<Mutex<Room>>,
player_id: &str,
preferred_team: Option<Team>,
) -> Team {
let mut room_guard = room.lock().await;
let team = room_guard
.game
.add_player(player_id.to_string(), preferred_team)
.unwrap_or(Team::Red);
if room_guard.game.can_start() {
room_guard.game.start();
}
team
}
fn spawn_forwarder(
@ -192,33 +160,25 @@ async fn broadcast_room_state(
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
) {
let room_guard = room.lock().await;
let msg = if room_guard.game.can_start() {
room_guard.game.game_state_message()
} else {
ServerMessage::Waiting {
message: "Waiting for other player".to_string(),
}
};
let _ = tx.send(msg);
let _ = tx.send(room_guard.game.game_state_message());
}
fn spawn_message_handler(
room: Arc<Mutex<Room>>,
player_id: String,
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 }) => {
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
let mut room_guard = room.lock().await;
match room_guard
.game
.process_throw(&player_id, broom_x, broom_y, weight, curl, friction)
.process_throw(team, broom_x, broom_y, weight, curl, friction)
{
Ok(ThrowOutcome {
trajectory,
@ -226,7 +186,7 @@ fn spawn_message_handler(
state_message,
game_over,
}) => {
let _ = tx.send(ServerMessage::Trajectory { path: trajectory });
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
if let Some(scored) = end_scored {
let _ = tx.send(scored);
}
@ -247,8 +207,3 @@ fn spawn_message_handler(
}
})
}
async fn remove_player(room: &Arc<Mutex<Room>>, player_id: &str) {
let mut room_guard = room.lock().await;
room_guard.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,7 +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 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;
@ -55,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,
@ -71,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,
@ -82,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 {
@ -104,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,
@ -113,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)
})
})

View File

@ -1,4 +1,4 @@
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type StoneState, type Team } from './protocol'
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 {
@ -27,10 +27,10 @@ export class GameModel {
pendingStones: StoneState[] = []
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
isDragging = false
isPanning = false
private activePath: [number, number, number][] = []
private activePaths = new Map<number, [number, number, number][]>()
private animationStartTime = 0
private animationTeam: Team = 'red'
setMyTeam(team: Team): void {
this.state.myTeam = team
@ -63,36 +63,104 @@ export class GameModel {
}
}
startTrajectory(path: [number, number, number][], team: Team): void {
this.activePath = trimPathToStartAtHogLine(path)
this.state.animating = this.activePath.length > 1
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.animationTeam = team
this.pendingStones = []
}
tick(now: number): DrawableStone | null {
if (!this.state.animating || this.activePath.length <= 1) {
return null
tick(now: number): DrawableStone[] {
if (!this.state.animating) {
return []
}
const elapsed = (now - this.animationStartTime) / 1000
const total = this.activePath[this.activePath.length - 1][2]
if (elapsed >= total) {
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 null
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 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++
const p0 = this.activePath[i]
const p1 = this.activePath[i + 1] ?? p0
const t0 = this.activePath[Math.max(i - 1, 0)]
const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)]
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
@ -100,7 +168,7 @@ export class GameModel {
const dx = t2[0] - t0[0]
const dy = t2[1] - t0[1]
const rotation = Math.atan2(dy, dx) * 2
return { x, y, rotation, team: this.animationTeam }
return { x, y, rotation }
}
get isMyTurn(): boolean {

View File

@ -1,7 +1,7 @@
import { connect, sendThrow, type NetCallbacks } from './net'
import { createRenderer } from './renderer'
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol'
import { type Team } from './protocol'
import { GameModel } from './game-model'
export function startGame(): void {
@ -13,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
@ -31,30 +35,17 @@ 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)
// connect() is invoked after callbacks is defined below.
const updateControls = () => {
const activeTeam = hud.teamSelect.value as Team
model.setMyTeam(activeTeam)
const myTurn = model.isMyTurn
velocity.setEnabled(myTurn)
curls.setEnabled(myTurn)
@ -63,6 +54,11 @@ export function startGame(): void {
hud.update(model.state)
}
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())
@ -85,8 +81,7 @@ export function startGame(): void {
}
const callbacks: NetCallbacks = {
onJoined: (_roomId, team) => {
model.setMyTeam(team)
onJoined: () => {
updateControls()
},
onWaiting: () => {
@ -97,8 +92,8 @@ export function startGame(): void {
model.updateGameState(msg)
updateControls()
},
onTrajectory: (path) => {
model.startTrajectory(path, model.state.turnTeam)
onTrajectory: (paths) => {
model.startTrajectory(paths)
updateControls()
},
onEndScored: (end, points, scoringTeam) => {
@ -128,36 +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 (!model.isMyTurn) return
model.isDragging = true
const pos = getPos(e as TouchEvent)
model.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 (!model.isDragging || !model.isMyTurn) return
if (model.isDragging && model.isMyTurn) {
e.preventDefault()
const pos = getPos(e as TouchEvent)
model.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 = () => {
model.isDragging = false
model.isPanning = false
}
canvas.addEventListener('touchstart', handleStart, { passive: false })
@ -170,9 +174,17 @@ export function startGame(): void {
throwBtn.addEventListener('click', () => {
if (!model.isMyTurn) return
sendThrow(model.broom.x, model.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()
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
@ -35,6 +36,7 @@ export interface DrawableStone {
export interface ClientThrowMessage {
type: 'throw'
team: Team
broom_x: number
broom_y: number
weight: number
@ -45,7 +47,6 @@ export interface ClientThrowMessage {
export interface ServerJoinedMessage {
type: 'joined'
room: string
team: Team
}
export interface ServerWaitingMessage {
@ -63,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

@ -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: DrawableStone | 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,
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
stones: StoneState[]
broom: { x: number; y: number } | null
animating: boolean
activeStonePos: DrawableStone | 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;
}
}