Compare commits
22 Commits
27e524a418
...
d631af71dc
| Author | SHA1 | Date | |
|---|---|---|---|
| d631af71dc | |||
|
|
473e18a751 | ||
|
|
1239394578 | ||
|
|
1e720c22cb | ||
|
|
2eb5760922 | ||
|
|
20c592d8ac | ||
|
|
2e11f005dc | ||
|
|
dc62c4f55f | ||
|
|
b06a78227f | ||
|
|
c37026b0a0 | ||
|
|
56a7d5abba | ||
|
|
c28bf32eb8 | ||
|
|
722e6090d1 | ||
|
|
a4e848c849 | ||
|
|
0e08e21c9b | ||
|
|
5eeaf8929d | ||
|
|
2f68325801 | ||
|
|
0ef0dc3d78 | ||
|
|
0ca0f0e3ff | ||
|
|
ef5349dfe3 | ||
|
|
23a0cafe18 | ||
|
|
1f673b4eba |
26
README.md
26
README.md
@ -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
|
||||
|
||||
@ -12,8 +12,6 @@ pub enum GamePhase {
|
||||
}
|
||||
|
||||
pub struct Game {
|
||||
red: Option<Player>,
|
||||
yellow: Option<Player>,
|
||||
phase: GamePhase,
|
||||
end: u8,
|
||||
scores: [i32; 2],
|
||||
@ -24,14 +22,18 @@ pub struct Game {
|
||||
stones_red: u8,
|
||||
stones_yellow: u8,
|
||||
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
||||
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
|
||||
}
|
||||
|
||||
pub struct ThrowOutcome {
|
||||
pub trajectory: Vec<StoneTrajectory>,
|
||||
pub end_scored: Option<ServerMessage>,
|
||||
pub state_message: ServerMessage,
|
||||
pub game_over: Option<ServerMessage>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
red: None,
|
||||
yellow: None,
|
||||
phase: GamePhase::Waiting,
|
||||
end: 1,
|
||||
scores: [0, 0],
|
||||
@ -42,81 +44,10 @@ impl Game {
|
||||
stones_red: STONES_PER_TEAM,
|
||||
stones_yellow: STONES_PER_TEAM,
|
||||
last_end_scored: None,
|
||||
room_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
|
||||
let team = match preferred {
|
||||
Some(Team::Red) if self.red.is_none() => Some(Team::Red),
|
||||
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow),
|
||||
Some(_) => {
|
||||
// Preferred is taken; fall back to the other free team.
|
||||
if self.red.is_none() {
|
||||
Some(Team::Red)
|
||||
} else if self.yellow.is_none() {
|
||||
Some(Team::Yellow)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
None => {
|
||||
// Legacy first-free logic.
|
||||
if self.red.is_none() {
|
||||
Some(Team::Red)
|
||||
} else if self.yellow.is_none() {
|
||||
Some(Team::Yellow)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(t) = team {
|
||||
let player = Player { id, team: t, connected: true };
|
||||
match t {
|
||||
Team::Red => self.red = Some(player),
|
||||
Team::Yellow => self.yellow = Some(player),
|
||||
}
|
||||
Some(t)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
|
||||
if let Some(ref p) = self.red {
|
||||
if p.id == id {
|
||||
self.red = None;
|
||||
return Some(Team::Red);
|
||||
}
|
||||
}
|
||||
if let Some(ref p) = self.yellow {
|
||||
if p.id == id {
|
||||
self.yellow = None;
|
||||
return Some(Team::Yellow);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn set_player_connected(&mut self, id: &str, connected: bool) {
|
||||
if let Some(p) = self.red.as_mut().filter(|p| p.id == id) {
|
||||
p.connected = connected;
|
||||
}
|
||||
if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) {
|
||||
p.connected = connected;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn can_start(&self) -> bool {
|
||||
self.red.is_some() && self.yellow.is_some()
|
||||
}
|
||||
|
||||
pub fn start(&mut self) {
|
||||
if !self.can_start() {
|
||||
return;
|
||||
}
|
||||
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
||||
self.turn_team = self.hammer.other();
|
||||
self.phase = GamePhase::Playing;
|
||||
@ -129,37 +60,24 @@ impl Game {
|
||||
self.active_stones.clear();
|
||||
}
|
||||
|
||||
pub fn current_player_id(&self) -> Option<&str> {
|
||||
let p = match self.turn_team {
|
||||
Team::Red => self.red.as_ref()?,
|
||||
Team::Yellow => self.yellow.as_ref()?,
|
||||
};
|
||||
Some(&p.id)
|
||||
}
|
||||
|
||||
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
|
||||
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
|
||||
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
|
||||
None
|
||||
}
|
||||
|
||||
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||
let current_id = self.current_player_id().ok_or("No current player")?;
|
||||
if current_id != player_id {
|
||||
pub fn handle_throw(
|
||||
&mut self,
|
||||
team: Team,
|
||||
broom_x: f32,
|
||||
broom_y: f32,
|
||||
weight: u8,
|
||||
curl: i8,
|
||||
friction: f32,
|
||||
) -> Result<Vec<StoneTrajectory>, String> {
|
||||
if self.turn_team != team {
|
||||
return Err("Not your turn".to_string());
|
||||
}
|
||||
if self.phase != GamePhase::Playing {
|
||||
return Err("Cannot throw now".to_string());
|
||||
}
|
||||
let dx = broom_x - HOUSE_CENTER.0;
|
||||
let dy = broom_y - HOUSE_CENTER.1;
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
if dist > HOUSE_RADIUS {
|
||||
return Err("Broom must be in the house".to_string());
|
||||
}
|
||||
|
||||
self.active_stones.clear();
|
||||
let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
||||
let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
||||
self.active_stones = self.physics.current_stones();
|
||||
self.phase = GamePhase::Simulating;
|
||||
|
||||
@ -168,7 +86,35 @@ impl Game {
|
||||
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
Ok(trajectory)
|
||||
}
|
||||
|
||||
pub fn process_throw(
|
||||
&mut self,
|
||||
team: Team,
|
||||
broom_x: f32,
|
||||
broom_y: f32,
|
||||
weight: u8,
|
||||
curl: i8,
|
||||
friction: f32,
|
||||
) -> Result<ThrowOutcome, String> {
|
||||
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
|
||||
self.finish_simulation();
|
||||
|
||||
let end_scored = self.take_last_end_scored();
|
||||
let state_message = self.game_state_message();
|
||||
let game_over = if self.phase == GamePhase::GameComplete {
|
||||
Some(self.game_over_message())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(ThrowOutcome {
|
||||
trajectory,
|
||||
end_scored,
|
||||
state_message,
|
||||
game_over,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn finish_simulation(&mut self) {
|
||||
@ -179,10 +125,6 @@ impl Game {
|
||||
self.score_end_internal(false);
|
||||
}
|
||||
|
||||
pub fn score_end(&mut self) {
|
||||
self.score_end_internal(true);
|
||||
}
|
||||
|
||||
fn score_end_internal(&mut self, force: bool) {
|
||||
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
|
||||
if !end_done {
|
||||
@ -220,7 +162,7 @@ impl Game {
|
||||
Team::Red => 0,
|
||||
Team::Yellow => 1,
|
||||
};
|
||||
self.scores[team_idx] += points as i32;
|
||||
self.scores[team_idx] += points;
|
||||
self.hammer = team.other();
|
||||
} else {
|
||||
points = 0;
|
||||
@ -229,10 +171,10 @@ impl Game {
|
||||
|
||||
self.last_end_scored = Some((self.end, points, scoring_team));
|
||||
self.phase = GamePhase::EndComplete;
|
||||
self.end_ends_or_continue(points, scoring_team);
|
||||
self.advance_end_or_finish();
|
||||
}
|
||||
|
||||
fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option<Team>) {
|
||||
fn advance_end_or_finish(&mut self) {
|
||||
let tied = self.scores[0] == self.scores[1];
|
||||
let after_regulation = self.end >= ENDS;
|
||||
|
||||
@ -241,10 +183,6 @@ impl Game {
|
||||
return;
|
||||
}
|
||||
|
||||
if after_regulation && tied {
|
||||
// Extra end
|
||||
}
|
||||
|
||||
self.end += 1;
|
||||
self.stones_red = STONES_PER_TEAM;
|
||||
self.stones_yellow = STONES_PER_TEAM;
|
||||
@ -255,10 +193,6 @@ impl Game {
|
||||
self.phase = GamePhase::Playing;
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> GamePhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
|
||||
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
|
||||
ServerMessage::EndScored { end, points, scoring_team }
|
||||
@ -300,24 +234,18 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
pub type RoomId = String;
|
||||
|
||||
pub struct Room {
|
||||
pub id: RoomId,
|
||||
pub game: Game,
|
||||
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new(id: &str) -> Self {
|
||||
pub fn new(_id: &str) -> Self {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||
let mut room = Self {
|
||||
id: id.to_string(),
|
||||
Self {
|
||||
game: Game::new(),
|
||||
tx,
|
||||
};
|
||||
room.game.room_tx = Some(room.tx.clone());
|
||||
room
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,29 +254,46 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefers_red_when_requested_and_free() {
|
||||
let mut game = Game::new();
|
||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
||||
fn starts_in_waiting_phase() {
|
||||
let game = Game::new();
|
||||
assert!(matches!(game.phase, GamePhase::Waiting));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefers_yellow_when_requested_and_free() {
|
||||
fn starts_when_called() {
|
||||
let mut game = Game::new();
|
||||
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
|
||||
game.start();
|
||||
assert!(matches!(game.phase, GamePhase::Playing));
|
||||
assert_eq!(game.end, 1);
|
||||
assert_eq!(game.scores, [0, 0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn falls_back_when_preferred_taken() {
|
||||
fn rejects_throw_for_wrong_team() {
|
||||
let mut game = Game::new();
|
||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
||||
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
|
||||
game.start();
|
||||
let turn = game.turn_team;
|
||||
let wrong = turn.other();
|
||||
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_order_without_preference() {
|
||||
fn accepts_throw_for_turn_team() {
|
||||
let mut game = Game::new();
|
||||
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
|
||||
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
|
||||
assert_eq!(game.add_player("p3".into(), None), None);
|
||||
game.start();
|
||||
let turn = game.turn_team;
|
||||
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_broom_outside_house() {
|
||||
let mut game = Game::new();
|
||||
game.start();
|
||||
let turn = game.turn_team;
|
||||
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
|
||||
let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0);
|
||||
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,30 +13,35 @@ use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
|
||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::game::{GamePhase, Room};
|
||||
use crate::game::{Room, ThrowOutcome};
|
||||
use crate::protocol::*;
|
||||
|
||||
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
|
||||
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
|
||||
rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
|
||||
Self {
|
||||
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
|
||||
let mut rooms = self.rooms.lock().unwrap();
|
||||
async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
|
||||
let mut rooms = self.rooms.lock().await;
|
||||
if let Some(room) = rooms.get(room_id).cloned() {
|
||||
return room;
|
||||
}
|
||||
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
|
||||
let room = Arc::new(Mutex::new(Room::new(room_id)));
|
||||
rooms.insert(room_id.to_string(), room.clone());
|
||||
room
|
||||
}
|
||||
@ -45,7 +50,6 @@ impl AppState {
|
||||
#[derive(Deserialize)]
|
||||
struct RoomQuery {
|
||||
room: String,
|
||||
team: Option<Team>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@ -81,7 +85,7 @@ async fn health() -> impl IntoResponse {
|
||||
|
||||
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let room_id = generate_room_code();
|
||||
state.get_or_create_room(&room_id);
|
||||
state.get_or_create_room(&room_id).await;
|
||||
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
|
||||
}
|
||||
|
||||
@ -90,53 +94,50 @@ async fn ws_handler(
|
||||
Query(query): Query<RoomQuery>,
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> impl IntoResponse {
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
|
||||
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room))
|
||||
}
|
||||
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String, preferred_team: Option<Team>) {
|
||||
let room_arc = state.get_or_create_room(&room_id);
|
||||
let player_id = Uuid::new_v4().to_string();
|
||||
let player_id_for_recv = player_id.clone();
|
||||
async fn handle_socket(
|
||||
socket: WebSocket,
|
||||
state: Arc<AppState>,
|
||||
room_id: String,
|
||||
) {
|
||||
let room = state.get_or_create_room(&room_id).await;
|
||||
let (mut sender, receiver) = socket.split();
|
||||
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Reject room-full before subscribing so the error goes only to the joining socket.
|
||||
{
|
||||
let room = room_arc.lock().await;
|
||||
if room.game.can_start() {
|
||||
let err = serde_json::to_string(&ServerMessage::Error {
|
||||
message: "Room is full".to_string(),
|
||||
}).unwrap();
|
||||
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
|
||||
let mut room_guard = room.lock().await;
|
||||
if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) {
|
||||
room_guard.game.start();
|
||||
}
|
||||
}
|
||||
|
||||
let joined = serde_json::to_string(&ServerMessage::Joined {
|
||||
room: room_id.clone(),
|
||||
})
|
||||
.unwrap();
|
||||
if sender.send(Message::Text(Utf8Bytes::from(joined))).await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let tx = { room.lock().await.tx.clone() };
|
||||
|
||||
let send_task = spawn_forwarder(sender, tx.subscribe());
|
||||
broadcast_room_state(&room, &tx).await;
|
||||
|
||||
let recv_task = spawn_message_handler(room.clone(), tx, receiver);
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {}
|
||||
_ = recv_task => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Add the player to the room and tell only this socket its assigned team.
|
||||
let team = {
|
||||
let mut room = room_arc.lock().await;
|
||||
let team = room.game.add_player(player_id.clone(), preferred_team)
|
||||
.unwrap_or(Team::Red);
|
||||
if room.game.can_start() {
|
||||
room.game.start();
|
||||
}
|
||||
team
|
||||
};
|
||||
|
||||
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
|
||||
room: room_id.clone(),
|
||||
team,
|
||||
}).unwrap();
|
||||
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
|
||||
|
||||
// Subscribe to broadcast and spawn the forwarding task.
|
||||
let tx = {
|
||||
let room = room_arc.lock().await;
|
||||
room.tx.clone()
|
||||
};
|
||||
let mut rx = tx.subscribe();
|
||||
|
||||
let send_task = tokio::spawn(async move {
|
||||
fn spawn_forwarder(
|
||||
mut sender: WsSender,
|
||||
mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => {
|
||||
@ -151,49 +152,51 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Broadcast waiting/game state to everyone in the room.
|
||||
{
|
||||
let room = room_arc.lock().await;
|
||||
if room.game.can_start() {
|
||||
let state_msg = room.game.game_state_message();
|
||||
let _ = room.tx.send(state_msg);
|
||||
} else {
|
||||
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let recv_room = room_arc.clone();
|
||||
let recv_id = player_id_for_recv;
|
||||
let recv_task = tokio::spawn(async move {
|
||||
async fn broadcast_room_state(
|
||||
room: &Arc<Mutex<Room>>,
|
||||
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
) {
|
||||
let room_guard = room.lock().await;
|
||||
let _ = tx.send(room_guard.game.game_state_message());
|
||||
}
|
||||
|
||||
fn spawn_message_handler(
|
||||
room: Arc<Mutex<Room>>,
|
||||
tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
mut receiver: WsReceiver,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
let Message::Text(text) = msg else { continue; };
|
||||
let Message::Text(text) = msg else { continue };
|
||||
let text_ref = text.as_str();
|
||||
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
||||
match parsed {
|
||||
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
|
||||
let room = recv_room.clone();
|
||||
let mut room = room.lock().await;
|
||||
if room.game.current_team_for_player(&recv_id) != Some(team) {
|
||||
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() });
|
||||
continue;
|
||||
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
|
||||
let mut room_guard = room.lock().await;
|
||||
match room_guard
|
||||
.game
|
||||
.process_throw(team, broom_x, broom_y, weight, curl, friction)
|
||||
{
|
||||
Ok(ThrowOutcome {
|
||||
trajectory,
|
||||
end_scored,
|
||||
state_message,
|
||||
game_over,
|
||||
}) => {
|
||||
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
|
||||
if let Some(scored) = end_scored {
|
||||
let _ = tx.send(scored);
|
||||
}
|
||||
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) {
|
||||
Ok(path) => {
|
||||
room.tx.send(ServerMessage::Trajectory { path }).ok();
|
||||
room.game.finish_simulation();
|
||||
if let Some(scored) = room.game.take_last_end_scored() {
|
||||
room.tx.send(scored).ok();
|
||||
}
|
||||
let after = room.game.game_state_message();
|
||||
room.tx.send(after).ok();
|
||||
if room.game.phase() == GamePhase::GameComplete {
|
||||
room.tx.send(room.game.game_over_message()).ok();
|
||||
let _ = tx.send(state_message);
|
||||
if let Some(over) = game_over {
|
||||
let _ = tx.send(over);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
room.tx.send(ServerMessage::Error { message: e }).ok();
|
||||
let _ = tx.send(ServerMessage::Error { message: e });
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -202,16 +205,5 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {}
|
||||
_ = recv_task => {}
|
||||
}
|
||||
|
||||
// On disconnect, free the team slot so refreshes and new tabs can rejoin.
|
||||
{
|
||||
let mut room = room_arc.lock().await;
|
||||
let _ = room.game.remove_player(&player_id);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ use std::fmt;
|
||||
pub const TICK_RATE_HZ: u16 = 120;
|
||||
pub const SAMPLE_RATE_HZ: u16 = 40;
|
||||
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
||||
pub const FEET_TO_METERS: f32 = 0.3048;
|
||||
|
||||
pub const STONES_PER_TEAM: u8 = 8;
|
||||
pub const ENDS: u8 = 10;
|
||||
@ -12,11 +13,7 @@ pub const ENDS: u8 = 10;
|
||||
pub const SHEET_WIDTH: f32 = 5.0;
|
||||
pub const SHEET_LENGTH: f32 = 45.0;
|
||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
|
||||
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
|
||||
pub const FOUR_FT_RADIUS: f32 = 0.6096;
|
||||
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
|
||||
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
|
||||
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
||||
pub const HOG_LINE_Y: f32 = 21.0;
|
||||
pub const BACK_LINE_Y: f32 = 42.0;
|
||||
pub const HACK_Y: f32 = 2.0;
|
||||
@ -59,6 +56,7 @@ impl fmt::Display for Team {
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ClientMessage {
|
||||
Throw {
|
||||
team: Team,
|
||||
broom_x: f32,
|
||||
broom_y: f32,
|
||||
weight: u8,
|
||||
@ -75,7 +73,7 @@ fn default_friction() -> f32 { 1.0 }
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ServerMessage {
|
||||
Joined { room: String, team: Team },
|
||||
Joined { room: String },
|
||||
Waiting { message: String },
|
||||
GameState {
|
||||
end: u8,
|
||||
@ -86,7 +84,7 @@ pub enum ServerMessage {
|
||||
phase: Phase,
|
||||
},
|
||||
Trajectory {
|
||||
path: Vec<(f32, f32, f32)>,
|
||||
paths: Vec<StoneTrajectory>,
|
||||
},
|
||||
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
||||
GameOver {
|
||||
@ -108,6 +106,12 @@ pub enum Phase {
|
||||
GameComplete,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoneTrajectory {
|
||||
pub stone_id: u32,
|
||||
pub path: Vec<(f32, f32, f32)>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoneState {
|
||||
pub id: u32,
|
||||
@ -117,10 +121,3 @@ pub struct StoneState {
|
||||
pub rotation: f32,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct Player {
|
||||
pub id: String,
|
||||
pub team: Team,
|
||||
pub connected: bool,
|
||||
}
|
||||
|
||||
72
e2e/collision_trajectory_qa.cjs
Normal file
72
e2e/collision_trajectory_qa.cjs
Normal 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) })
|
||||
@ -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
75
e2e/e2e_multi_client.cjs
Normal 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)
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
104
frontend/src/game-model.test.ts
Normal file
104
frontend/src/game-model.test.ts
Normal 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
182
frontend/src/game-model.ts
Normal file
@ -0,0 +1,182 @@
|
||||
import { HOG_LINE_Y, HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, type ServerStoneTrajectory, type StoneState, type Team } from './protocol'
|
||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
||||
|
||||
export interface GameModelState {
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turnTeam: Team
|
||||
myTeam: Team | null
|
||||
phase: Phase
|
||||
stones: StoneState[]
|
||||
animating: boolean
|
||||
}
|
||||
|
||||
export class GameModel {
|
||||
state: GameModelState = {
|
||||
end: 1,
|
||||
scores: [0, 0],
|
||||
hammer: 'red',
|
||||
turnTeam: 'red',
|
||||
myTeam: null,
|
||||
phase: 'waiting',
|
||||
stones: [],
|
||||
animating: false,
|
||||
}
|
||||
|
||||
pendingStones: StoneState[] = []
|
||||
broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
||||
isDragging = false
|
||||
isPanning = false
|
||||
|
||||
private activePaths = new Map<number, [number, number, number][]>()
|
||||
private animationStartTime = 0
|
||||
|
||||
setMyTeam(team: Team): void {
|
||||
this.state.myTeam = team
|
||||
}
|
||||
|
||||
setWaiting(): void {
|
||||
this.state.phase = 'waiting'
|
||||
}
|
||||
|
||||
updateGameState(msg: ServerGameStateMessage): void {
|
||||
const reset = msg.phase === 'waiting' || msg.end !== this.state.end
|
||||
if (reset) {
|
||||
this.state.stones = []
|
||||
this.pendingStones = []
|
||||
}
|
||||
|
||||
if (this.state.animating) {
|
||||
this.pendingStones = msg.stones
|
||||
} else {
|
||||
this.state.stones = msg.stones
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
end: msg.end,
|
||||
scores: msg.scores,
|
||||
hammer: msg.hammer,
|
||||
turnTeam: msg.turn_team,
|
||||
phase: msg.phase,
|
||||
}
|
||||
}
|
||||
|
||||
startTrajectory(paths: ServerStoneTrajectory[]): void {
|
||||
const existingIds = new Set(this.state.stones.map((s) => s.id))
|
||||
|
||||
let thrownId: number | null = null
|
||||
for (const { stone_id } of paths) {
|
||||
if (!existingIds.has(stone_id)) {
|
||||
thrownId = stone_id
|
||||
break
|
||||
}
|
||||
}
|
||||
if (thrownId === null && paths.length > 0) {
|
||||
thrownId = paths[0].stone_id
|
||||
}
|
||||
|
||||
let tRef = 0
|
||||
if (thrownId !== null) {
|
||||
const thrownPath = paths.find((p) => p.stone_id === thrownId)?.path ?? []
|
||||
if (thrownPath.length >= 2) {
|
||||
const idx = thrownPath.findIndex(([, y]) => y >= HOG_LINE_Y)
|
||||
if (idx >= 0) {
|
||||
const start = Math.max(0, idx - 1)
|
||||
tRef = thrownPath[start][2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const pathMap = new Map<number, [number, number, number][]>()
|
||||
for (const { stone_id, path } of paths) {
|
||||
if (stone_id === thrownId) {
|
||||
pathMap.set(stone_id, trimPathToStartAtHogLine(path))
|
||||
} else {
|
||||
const shifted = path
|
||||
.map(([x, y, t]) => [x, y, t - tRef] as [number, number, number])
|
||||
.filter(([, , t]) => t >= 0)
|
||||
pathMap.set(stone_id, shifted)
|
||||
}
|
||||
}
|
||||
|
||||
this.activePaths = pathMap
|
||||
this.state.animating = Array.from(pathMap.values()).some((p) => p.length > 1)
|
||||
this.animationStartTime = performance.now()
|
||||
this.pendingStones = []
|
||||
}
|
||||
|
||||
tick(now: number): DrawableStone[] {
|
||||
if (!this.state.animating) {
|
||||
return []
|
||||
}
|
||||
|
||||
const elapsed = (now - this.animationStartTime) / 1000
|
||||
const maxTotal = Math.max(
|
||||
0,
|
||||
...Array.from(this.activePaths.values()).map((p) => (p.length > 0 ? p[p.length - 1][2] : 0)),
|
||||
)
|
||||
|
||||
if (elapsed >= maxTotal) {
|
||||
this.state.animating = false
|
||||
if (this.pendingStones.length > 0) {
|
||||
this.state.stones = this.pendingStones
|
||||
this.pendingStones = []
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const result: DrawableStone[] = []
|
||||
for (const [stoneId, path] of this.activePaths) {
|
||||
const pos = this.interpolatePath(path, elapsed)
|
||||
if (!pos) continue
|
||||
const team = this.state.stones.find((s) => s.id === stoneId)?.team ?? this.state.turnTeam
|
||||
result.push({ ...pos, team })
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private interpolatePath(
|
||||
path: [number, number, number][],
|
||||
elapsed: number,
|
||||
): { x: number; y: number; rotation: number } | null {
|
||||
if (path.length === 0) return null
|
||||
if (path.length === 1) {
|
||||
const [x, y] = path[0]
|
||||
return { x, y, rotation: 0 }
|
||||
}
|
||||
|
||||
if (elapsed >= path[path.length - 1][2]) {
|
||||
const last = path[path.length - 1]
|
||||
const prev = path[path.length - 2]
|
||||
const dx = last[0] - prev[0]
|
||||
const dy = last[1] - prev[1]
|
||||
return { x: last[0], y: last[1], rotation: Math.atan2(dy, dx) * 2 }
|
||||
}
|
||||
|
||||
let i = 0
|
||||
while (i + 1 < path.length && path[i + 1][2] < elapsed) i++
|
||||
const p0 = path[i]
|
||||
const p1 = path[i + 1] ?? p0
|
||||
const t0 = path[Math.max(i - 1, 0)]
|
||||
const t2 = path[Math.min(i + 2, path.length - 1)]
|
||||
const dt = p1[2] - p0[2]
|
||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
const dx = t2[0] - t0[0]
|
||||
const dy = t2[1] - t0[1]
|
||||
const rotation = Math.atan2(dy, dx) * 2
|
||||
return { x, y, rotation }
|
||||
}
|
||||
|
||||
get isMyTurn(): boolean {
|
||||
return Boolean(
|
||||
this.state.myTeam &&
|
||||
this.state.turnTeam === this.state.myTeam &&
|
||||
this.state.phase === 'playing' &&
|
||||
!this.state.animating,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,19 +1,8 @@
|
||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||
import { createRenderer } from './renderer'
|
||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
|
||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
||||
|
||||
interface GameState {
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turnTeam: Team
|
||||
myTeam: Team | null
|
||||
phase: Phase
|
||||
stones: StoneState[]
|
||||
animating: boolean
|
||||
}
|
||||
import { type Team } from './protocol'
|
||||
import { GameModel } from './game-model'
|
||||
|
||||
export function startGame(): void {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
@ -24,6 +13,10 @@ export function startGame(): void {
|
||||
const hud = createHud()
|
||||
app.appendChild(hud.root)
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
|
||||
}
|
||||
|
||||
const velocityContainer = hud.velocityControl
|
||||
const curlContainer = hud.curlSelector
|
||||
const frictionContainer = hud.frictionControl
|
||||
@ -42,141 +35,65 @@ export function startGame(): void {
|
||||
const shareLink = `${window.location.origin}/?room=${room}`
|
||||
hud.setShareLink(shareLink)
|
||||
|
||||
const picker = document.createElement('div')
|
||||
picker.id = 'team-picker'
|
||||
picker.innerHTML = `
|
||||
<div class="team-picker-box">
|
||||
<h2>Choose your team</h2>
|
||||
<div class="team-picker-buttons">
|
||||
<button class="team-btn team-red" data-team="red">Play as Red</button>
|
||||
<button class="team-btn team-yellow" data-team="yellow">Play as Yellow</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
app.appendChild(picker)
|
||||
const model = new GameModel()
|
||||
|
||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const team = btn.dataset.team as Team
|
||||
picker.remove()
|
||||
connect(room, team, callbacks)
|
||||
})
|
||||
})
|
||||
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
|
||||
model.setMyTeam(initialTeam)
|
||||
hud.setTeam(initialTeam)
|
||||
|
||||
let gameState: GameState = {
|
||||
end: 1,
|
||||
scores: [0, 0],
|
||||
hammer: 'red',
|
||||
turnTeam: 'red',
|
||||
myTeam: null,
|
||||
phase: 'waiting',
|
||||
stones: [],
|
||||
animating: false,
|
||||
}
|
||||
let pendingStones: StoneState[] = []
|
||||
|
||||
let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
||||
let isDragging = false
|
||||
let activePath: [number, number, number][] = []
|
||||
let animationStartTime = 0
|
||||
let animationTeam: Team = 'red'
|
||||
// connect() is invoked after callbacks is defined below.
|
||||
|
||||
const updateControls = () => {
|
||||
const myTurn = Boolean(
|
||||
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
|
||||
)
|
||||
velocity.setEnabled(myTurn && !gameState.animating)
|
||||
curls.setEnabled(myTurn && !gameState.animating)
|
||||
friction.setEnabled(myTurn && !gameState.animating)
|
||||
throwBtn.disabled = !myTurn || gameState.animating
|
||||
if (gameState.phase === 'simulating' || gameState.animating) {
|
||||
velocity.setEnabled(false)
|
||||
curls.setEnabled(false)
|
||||
friction.setEnabled(false)
|
||||
}
|
||||
hud.update(gameState)
|
||||
const activeTeam = hud.teamSelect.value as Team
|
||||
model.setMyTeam(activeTeam)
|
||||
const myTurn = model.isMyTurn
|
||||
velocity.setEnabled(myTurn)
|
||||
curls.setEnabled(myTurn)
|
||||
friction.setEnabled(myTurn)
|
||||
throwBtn.disabled = !myTurn
|
||||
hud.update(model.state)
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
|
||||
if (gameState.animating && activePath.length > 1) {
|
||||
const elapsed = (performance.now() - animationStartTime) / 1000
|
||||
const total = activePath[activePath.length - 1][2]
|
||||
if (elapsed >= total) {
|
||||
gameState.animating = false
|
||||
if (pendingStones.length > 0) {
|
||||
gameState.stones = pendingStones
|
||||
pendingStones = []
|
||||
}
|
||||
hud.teamSelect.addEventListener('change', () => {
|
||||
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
||||
updateControls()
|
||||
})
|
||||
|
||||
const render = () => {
|
||||
const wasAnimating = model.state.animating
|
||||
const activeStonePos = model.tick(performance.now())
|
||||
if (wasAnimating && !model.state.animating) {
|
||||
updateControls()
|
||||
} else {
|
||||
let i = 0
|
||||
while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++
|
||||
const p0 = activePath[i]
|
||||
const p1 = activePath[i + 1] ?? p0
|
||||
const t0 = activePath[Math.max(i - 1, 0)]
|
||||
const t2 = activePath[Math.min(i + 2, activePath.length - 1)]
|
||||
const dt = p1[2] - p0[2]
|
||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
const dx = t2[0] - t0[0]
|
||||
const dy = t2[1] - t0[1]
|
||||
const rotation = Math.atan2(dy, dx) * 2
|
||||
activeStonePos = { x, y, rotation, team: animationTeam }
|
||||
}
|
||||
}
|
||||
|
||||
renderer.draw({
|
||||
stones: gameState.stones,
|
||||
stones: model.state.stones,
|
||||
broom:
|
||||
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
|
||||
? broom
|
||||
model.state.phase === 'playing' &&
|
||||
!model.state.animating &&
|
||||
model.state.myTeam === model.state.turnTeam
|
||||
? model.broom
|
||||
: null,
|
||||
animating: gameState.animating,
|
||||
animating: model.state.animating,
|
||||
activeStonePos,
|
||||
})
|
||||
requestAnimationFrame(render)
|
||||
}
|
||||
|
||||
const callbacks: NetCallbacks = {
|
||||
onJoined: (_roomId, team) => {
|
||||
gameState.myTeam = team
|
||||
onJoined: () => {
|
||||
updateControls()
|
||||
},
|
||||
onWaiting: () => {
|
||||
gameState.phase = 'waiting'
|
||||
model.setWaiting()
|
||||
updateControls()
|
||||
},
|
||||
onGameState: (msg) => {
|
||||
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
|
||||
if (reset) {
|
||||
gameState.stones = []
|
||||
pendingStones = []
|
||||
}
|
||||
|
||||
if (gameState.animating) {
|
||||
pendingStones = msg.stones
|
||||
} else {
|
||||
gameState.stones = msg.stones
|
||||
}
|
||||
|
||||
gameState = {
|
||||
...gameState,
|
||||
end: msg.end,
|
||||
scores: msg.scores,
|
||||
hammer: msg.hammer,
|
||||
turnTeam: msg.turn_team,
|
||||
phase: msg.phase,
|
||||
}
|
||||
model.updateGameState(msg)
|
||||
updateControls()
|
||||
},
|
||||
onTrajectory: (path) => {
|
||||
activePath = trimPathToStartAtHogLine(path)
|
||||
gameState.animating = activePath.length > 1
|
||||
animationStartTime = performance.now()
|
||||
animationTeam = gameState.turnTeam
|
||||
pendingStones = []
|
||||
onTrajectory: (paths) => {
|
||||
model.startTrajectory(paths)
|
||||
updateControls()
|
||||
},
|
||||
onEndScored: (end, points, scoringTeam) => {
|
||||
@ -206,45 +123,45 @@ export function startGame(): void {
|
||||
return { x: e.clientX, y: e.clientY }
|
||||
}
|
||||
|
||||
const constrainBroom = (world: { x: number; y: number }) => {
|
||||
const dx = world.x - HOUSE_CENTER.x
|
||||
const dy = world.y - HOUSE_CENTER.y
|
||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
||||
if (dist > HOUSE_RADIUS) {
|
||||
const ratio = HOUSE_RADIUS / dist
|
||||
return {
|
||||
x: HOUSE_CENTER.x + dx * ratio,
|
||||
y: HOUSE_CENTER.y + dy * ratio,
|
||||
}
|
||||
}
|
||||
return world
|
||||
}
|
||||
let lastPanScreenY = 0
|
||||
|
||||
const handleStart = (e: Event) => {
|
||||
if (!isMyDragTurn()) return
|
||||
isDragging = true
|
||||
const pos = getPos(e as TouchEvent)
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
const world = renderer.screenToWorld(pos.x, pos.y)
|
||||
|
||||
if (model.isMyTurn) {
|
||||
model.isDragging = true
|
||||
model.broom = world
|
||||
} else {
|
||||
model.isPanning = true
|
||||
lastPanScreenY = pos.y
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = (e: Event) => {
|
||||
if (!isDragging || !isMyDragTurn()) return
|
||||
if (model.isDragging && model.isMyTurn) {
|
||||
e.preventDefault()
|
||||
const pos = getPos(e as TouchEvent)
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
model.broom = renderer.screenToWorld(pos.x, pos.y)
|
||||
return
|
||||
}
|
||||
|
||||
if (model.isPanning) {
|
||||
e.preventDefault()
|
||||
const pos = getPos(e as TouchEvent)
|
||||
const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY)
|
||||
const nowWorld = renderer.screenToWorld(pos.x, pos.y)
|
||||
// Direct-manipulation pan: dragging the sheet down reveals the hog line above.
|
||||
const deltaY = nowWorld.y - prevWorld.y
|
||||
const sensitivity = 1.5
|
||||
renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity)
|
||||
lastPanScreenY = pos.y
|
||||
}
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
const isMyDragTurn = () => {
|
||||
return (
|
||||
gameState.myTeam !== null &&
|
||||
gameState.turnTeam === gameState.myTeam &&
|
||||
gameState.phase === 'playing' &&
|
||||
!gameState.animating
|
||||
)
|
||||
model.isDragging = false
|
||||
model.isPanning = false
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||
@ -256,10 +173,18 @@ export function startGame(): void {
|
||||
canvas.addEventListener('mouseleave', handleEnd)
|
||||
|
||||
throwBtn.addEventListener('click', () => {
|
||||
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
|
||||
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
||||
if (!model.isMyTurn) return
|
||||
sendThrow(
|
||||
hud.teamSelect.value as Team,
|
||||
model.broom.x,
|
||||
model.broom.y,
|
||||
velocity.getWeight(),
|
||||
curls.getSelected(),
|
||||
friction.getFriction(),
|
||||
)
|
||||
})
|
||||
|
||||
connect(room, callbacks)
|
||||
updateControls()
|
||||
render()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
@ -4,12 +4,13 @@ export const STONES_PER_TEAM = 8
|
||||
export const ENDS = 10
|
||||
export const SHEET_WIDTH = 5.0
|
||||
export const SHEET_LENGTH = 45.0
|
||||
export const FEET_TO_METERS = 0.3048
|
||||
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
||||
export const HOUSE_RADIUS = 1.83
|
||||
export const BUTTON_RADIUS = 0.1524
|
||||
export const FOUR_FT_RADIUS = 0.6096
|
||||
export const EIGHT_FT_RADIUS = 1.2192
|
||||
export const TWELVE_FT_RADIUS = 1.8288
|
||||
export const HOUSE_RADIUS = 6 * FEET_TO_METERS
|
||||
export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS
|
||||
export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
|
||||
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
||||
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
||||
export const HOG_LINE_Y = 21.0
|
||||
export const BACK_LINE_Y = 42.0
|
||||
export const HACK_Y = 2.0
|
||||
@ -26,8 +27,16 @@ export interface StoneState {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface DrawableStone {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
team: Team
|
||||
}
|
||||
|
||||
export interface ClientThrowMessage {
|
||||
type: 'throw'
|
||||
team: Team
|
||||
broom_x: number
|
||||
broom_y: number
|
||||
weight: number
|
||||
@ -38,7 +47,6 @@ export interface ClientThrowMessage {
|
||||
export interface ServerJoinedMessage {
|
||||
type: 'joined'
|
||||
room: string
|
||||
team: Team
|
||||
}
|
||||
|
||||
export interface ServerWaitingMessage {
|
||||
@ -56,9 +64,14 @@ export interface ServerGameStateMessage {
|
||||
phase: Phase
|
||||
}
|
||||
|
||||
export interface ServerStoneTrajectory {
|
||||
stone_id: number
|
||||
path: [number, number, number][]
|
||||
}
|
||||
|
||||
export interface ServerTrajectoryMessage {
|
||||
type: 'trajectory'
|
||||
path: [number, number, number][]
|
||||
paths: ServerStoneTrajectory[]
|
||||
}
|
||||
|
||||
export interface ServerEndScoredMessage {
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
STONE_RADIUS,
|
||||
TWELVE_FT_RADIUS,
|
||||
HOUSE_CENTER,
|
||||
type DrawableStone,
|
||||
type StoneState,
|
||||
type Team,
|
||||
} from './protocol'
|
||||
|
||||
export interface Renderer {
|
||||
@ -19,19 +19,31 @@ export interface Renderer {
|
||||
setSize: () => void
|
||||
worldToScreen: (x: number, y: number) => { x: number; y: number }
|
||||
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
|
||||
setViewYOffset: (y: number) => void
|
||||
clampViewYOffset: () => number
|
||||
draw: (state: {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
activeStonePos: DrawableStone[]
|
||||
}) => void
|
||||
}
|
||||
|
||||
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
const ctx = canvas.getContext('2d')!
|
||||
// Show the area from the hog line (top) to the back line (bottom).
|
||||
const viewportHeight = BACK_LINE_Y - HOG_LINE_Y
|
||||
const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 }
|
||||
// Fit full sheet width (5 m) with a little side padding so both sidelines show.
|
||||
// Scale is primarily width-based, but also capped so house + backline fit above the HUD.
|
||||
const SIDE_PADDING_M = 0.25
|
||||
const viewportWidth = SHEET_WIDTH + SIDE_PADDING_M * 2
|
||||
// Min world height: top of 12ft ring through backline + small pad.
|
||||
const MIN_VIEW_HEIGHT_M =
|
||||
BACK_LINE_Y - (HOUSE_CENTER.y - TWELVE_FT_RADIUS) + SIDE_PADDING_M
|
||||
// Throw button (80) + hud padding (12×2) + small gap above controls.
|
||||
const BOTTOM_UI_PX = 120
|
||||
const viewportCenter = { x: 0, y: HOUSE_CENTER.y }
|
||||
// Prefer backline at the bottom of the usable ice area (just above HUD).
|
||||
// First setSize()/setViewYOffset clamps this to the valid minOffset.
|
||||
let viewportYOffset = -(BACK_LINE_Y - HOUSE_CENTER.y)
|
||||
|
||||
const setSize = () => {
|
||||
const dpr = window.devicePixelRatio || 1
|
||||
@ -42,33 +54,75 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
canvas.style.width = `${width}px`
|
||||
canvas.style.height = `${height}px`
|
||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||
// Re-clamp so default framing keeps the backline just above the HUD.
|
||||
setViewYOffset(viewportYOffset)
|
||||
}
|
||||
|
||||
/** Screen height available for ice (above bottom controls). */
|
||||
const usableHeightPx = (): number => {
|
||||
return Math.max(1, window.innerHeight - BOTTOM_UI_PX)
|
||||
}
|
||||
|
||||
const scale = (): number => {
|
||||
const height = window.innerHeight
|
||||
return height / viewportHeight
|
||||
const byWidth = window.innerWidth / viewportWidth
|
||||
const byHeight = usableHeightPx() / MIN_VIEW_HEIGHT_M
|
||||
// Width fit for sidelines; height cap so house/backline stay on-screen on wide displays.
|
||||
return Math.min(byWidth, byHeight)
|
||||
}
|
||||
|
||||
const viewportHeight = (): number => {
|
||||
return usableHeightPx() / scale()
|
||||
}
|
||||
|
||||
/** Vertical center of the usable ice area (not the full window). */
|
||||
const viewCenterY = (): number => {
|
||||
return usableHeightPx() / 2
|
||||
}
|
||||
|
||||
const effectiveViewportCenter = () => ({
|
||||
x: viewportCenter.x,
|
||||
y: viewportCenter.y - viewportYOffset,
|
||||
})
|
||||
|
||||
const worldToScreen = (x: number, y: number) => {
|
||||
const s = scale()
|
||||
const cx = window.innerWidth / 2
|
||||
const cy = window.innerHeight / 2
|
||||
const cy = viewCenterY()
|
||||
const center = effectiveViewportCenter()
|
||||
return {
|
||||
x: cx + (x - viewportCenter.x) * s,
|
||||
y: cy + (y - viewportCenter.y) * s,
|
||||
x: cx + (x - center.x) * s,
|
||||
y: cy + (y - center.y) * s,
|
||||
}
|
||||
}
|
||||
|
||||
const screenToWorld = (sx: number, sy: number) => {
|
||||
const s = scale()
|
||||
const cx = window.innerWidth / 2
|
||||
const cy = window.innerHeight / 2
|
||||
const cy = viewCenterY()
|
||||
const center = effectiveViewportCenter()
|
||||
return {
|
||||
x: (sx - cx) / s + viewportCenter.x,
|
||||
y: viewportCenter.y + (sy - cy) / s,
|
||||
x: (sx - cx) / s + center.x,
|
||||
y: center.y + (sy - cy) / s,
|
||||
}
|
||||
}
|
||||
|
||||
const clampViewYOffset = (): number => {
|
||||
// Positive offset pans the view upward (toward the hog line).
|
||||
// When the usable viewport is tall enough, minOffset pins BACK_LINE_Y to the
|
||||
// bottom of the usable area (just above the HUD), not under the controls.
|
||||
const halfHeight = viewportHeight() / 2
|
||||
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
|
||||
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
|
||||
return Math.max(minOffset, Math.min(maxOffset, viewportYOffset))
|
||||
}
|
||||
|
||||
const setViewYOffset = (y: number) => {
|
||||
const halfHeight = viewportHeight() / 2
|
||||
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
|
||||
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
|
||||
viewportYOffset = Math.max(minOffset, Math.min(maxOffset, y))
|
||||
}
|
||||
|
||||
const drawLine = (
|
||||
x1: number,
|
||||
y1: number,
|
||||
@ -102,7 +156,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
const drawStone = (stone: StoneState | { x: number; y: number; rotation: number; team: Team }) => {
|
||||
const drawStone = (stone: DrawableStone) => {
|
||||
const c = worldToScreen(stone.x, stone.y)
|
||||
const r = STONE_RADIUS * scale()
|
||||
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
||||
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
activeStonePos: DrawableStone[]
|
||||
}) => {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||
|
||||
@ -149,12 +203,14 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)')
|
||||
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff')
|
||||
|
||||
if (state.animating) {
|
||||
for (const stone of state.activeStonePos) {
|
||||
drawStone(stone)
|
||||
}
|
||||
} else {
|
||||
for (const stone of state.stones) {
|
||||
drawStone(stone)
|
||||
}
|
||||
|
||||
if (state.activeStonePos) {
|
||||
drawStone(state.activeStonePos)
|
||||
}
|
||||
|
||||
if (state.broom) {
|
||||
@ -170,5 +226,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
setSize()
|
||||
window.addEventListener('resize', setSize)
|
||||
|
||||
return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw }
|
||||
return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw }
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user