jasonlooked #1
26
README.md
26
README.md
@ -1,6 +1,6 @@
|
|||||||
## curltastic
|
## curltastic
|
||||||
|
|
||||||
A 1v1 async multiplayer 2D curling game for mobile browser.
|
A multiplayer 2D curling game for mobile browser. Any number of clients can join a room, pick Red or Yellow freely, and throw when it is that team's turn (solo pass-and-play or remote opponents).
|
||||||
|
|
||||||
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
- **Backend** — Rust, Axum, WebSocket, Rapier2D physics (server-authoritative, 120 Hz).
|
||||||
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
- **Frontend** — TypeScript, Vite, Canvas2D, portrait-first touch UI.
|
||||||
@ -21,22 +21,24 @@ A 1v1 async multiplayer 2D curling game for mobile browser.
|
|||||||
npm run dev
|
npm run dev
|
||||||
# opens on 0.0.0.0:5173 by default
|
# opens on 0.0.0.0:5173 by default
|
||||||
```
|
```
|
||||||
4. Open two browser tabs to the same room URL, e.g.:
|
4. Open one or more browser tabs to the same room URL, e.g.:
|
||||||
```
|
```
|
||||||
http://localhost:5173/?room=DEMO1
|
http://localhost:5173/?room=DEMO1
|
||||||
```
|
```
|
||||||
The first tab waits; the second starts the match. Use the *Copy share link* button to invite an opponent.
|
Use the team dropdown in the top-right to switch between Red and Yellow — this enables local pass-and-play on one device. Use the *Copy share link* button to invite an opponent on another device.
|
||||||
|
|
||||||
### Mobile devices
|
### Mobile devices
|
||||||
|
|
||||||
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`.
|
The frontend binds to `0.0.0.0` via `--host`. Find your machine's LAN IP and open `http://<ip>:5173/?room=CODE` on the phone. Both devices must be on the same Wi-Fi and able to reach the backend on port `3000`. The default view is zoomed in on the house; drag the sheet vertically to scroll up to the hog line.
|
||||||
|
|
||||||
### Controls
|
### Controls
|
||||||
|
|
||||||
- Drag inside the house to place the broom (aim point).
|
- When it is your team's turn, drag on the sheet to place the broom (aim point) — aim is not limited to the house.
|
||||||
- Tap a weight `1`–`10`.
|
- When it is not your turn, drag to pan the ice (default framing shows the house with sidelines; pan up toward the hog line).
|
||||||
- Tap **THROW**.
|
- Use the team dropdown to choose which team's stone you are throwing; switch any time (including mid-end for solo play).
|
||||||
- The server runs the physics and streams the trajectory; the frontend interpolates the animation.
|
- Use the left/right curl buttons and the weight/friction controls; friction is a local scalar.
|
||||||
|
- Tap **THROW**. Anyone identifying as the turn team may throw.
|
||||||
|
- The server runs physics for every stone and streams multi-stone paths; the client animates them on one clock so collisions move together.
|
||||||
|
|
||||||
### Architecture
|
### Architecture
|
||||||
|
|
||||||
@ -52,8 +54,12 @@ With the backend and frontend dev server running:
|
|||||||
```bash
|
```bash
|
||||||
cd e2e
|
cd e2e
|
||||||
npm install -g ws # or npm install ws locally in the project
|
npm install -g ws # or npm install ws locally in the project
|
||||||
node e2e_test.cjs # room lifecycle, throw, trajectory, out-of-play removal
|
node e2e_test.cjs # room lifecycle, throw, trajectory
|
||||||
node e2e_score.cjs # stones remain in play and alternate turns
|
node e2e_score.cjs # alternate turns / stones in play
|
||||||
|
node e2e_persistence.cjs # multi-throw stone persistence
|
||||||
|
node e2e_multi_client.cjs # 3 clients share state (no room-full)
|
||||||
|
node e2e_end_score.cjs # full end → end_scored + next end
|
||||||
|
node collision_trajectory_qa.cjs # multi-stone trajectory on collision
|
||||||
```
|
```
|
||||||
|
|
||||||
### Limitations / known simplifications
|
### Limitations / known simplifications
|
||||||
|
|||||||
@ -12,8 +12,6 @@ pub enum GamePhase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub struct Game {
|
pub struct Game {
|
||||||
red: Option<Player>,
|
|
||||||
yellow: Option<Player>,
|
|
||||||
phase: GamePhase,
|
phase: GamePhase,
|
||||||
end: u8,
|
end: u8,
|
||||||
scores: [i32; 2],
|
scores: [i32; 2],
|
||||||
@ -24,14 +22,18 @@ pub struct Game {
|
|||||||
stones_red: u8,
|
stones_red: u8,
|
||||||
stones_yellow: u8,
|
stones_yellow: u8,
|
||||||
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
||||||
|
|
|||||||
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
|
}
|
||||||
|
|
||||||
|
pub struct ThrowOutcome {
|
||||||
|
pub trajectory: Vec<StoneTrajectory>,
|
||||||
|
pub end_scored: Option<ServerMessage>,
|
||||||
|
pub state_message: ServerMessage,
|
||||||
|
pub game_over: Option<ServerMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Game {
|
impl Game {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
red: None,
|
|
||||||
yellow: None,
|
|
||||||
phase: GamePhase::Waiting,
|
phase: GamePhase::Waiting,
|
||||||
end: 1,
|
end: 1,
|
||||||
scores: [0, 0],
|
scores: [0, 0],
|
||||||
@ -42,81 +44,10 @@ impl Game {
|
|||||||
stones_red: STONES_PER_TEAM,
|
stones_red: STONES_PER_TEAM,
|
||||||
stones_yellow: STONES_PER_TEAM,
|
stones_yellow: STONES_PER_TEAM,
|
||||||
last_end_scored: None,
|
last_end_scored: None,
|
||||||
room_tx: None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
eros
commented
on the frontend, use a skeumorphic HUD to display how many stones are left. for instance, at the start of the game, show 2 rows of 8 stones. a stone should be removed from the hud when it is thrown. on the frontend, use a skeumorphic HUD to display how many stones are left. for instance, at the start of the game, show 2 rows of 8 stones. a stone should be removed from the hud when it is thrown.
|
|||||||
|
|
||||||
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
|
|
||||||
let team = match preferred {
|
|
||||||
Some(Team::Red) if self.red.is_none() => Some(Team::Red),
|
|
||||||
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow),
|
|
||||||
Some(_) => {
|
|
||||||
// Preferred is taken; fall back to the other free team.
|
|
||||||
if self.red.is_none() {
|
|
||||||
Some(Team::Red)
|
|
||||||
} else if self.yellow.is_none() {
|
|
||||||
Some(Team::Yellow)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
// Legacy first-free logic.
|
|
||||||
if self.red.is_none() {
|
|
||||||
Some(Team::Red)
|
|
||||||
} else if self.yellow.is_none() {
|
|
||||||
Some(Team::Yellow)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
if let Some(t) = team {
|
|
||||||
let player = Player { id, team: t, connected: true };
|
|
||||||
match t {
|
|
||||||
Team::Red => self.red = Some(player),
|
|
||||||
Team::Yellow => self.yellow = Some(player),
|
|
||||||
}
|
|
||||||
Some(t)
|
|
||||||
} else {
|
|
||||||
None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
|
|
||||||
if let Some(ref p) = self.red {
|
|
||||||
if p.id == id {
|
|
||||||
self.red = None;
|
|
||||||
return Some(Team::Red);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let Some(ref p) = self.yellow {
|
|
||||||
if p.id == id {
|
|
||||||
self.yellow = None;
|
|
||||||
return Some(Team::Yellow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_player_connected(&mut self, id: &str, connected: bool) {
|
|
||||||
if let Some(p) = self.red.as_mut().filter(|p| p.id == id) {
|
|
||||||
p.connected = connected;
|
|
||||||
}
|
|
||||||
if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) {
|
|
||||||
p.connected = connected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn can_start(&self) -> bool {
|
|
||||||
self.red.is_some() && self.yellow.is_some()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn start(&mut self) {
|
pub fn start(&mut self) {
|
||||||
if !self.can_start() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
||||||
self.turn_team = self.hammer.other();
|
self.turn_team = self.hammer.other();
|
||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
@ -129,37 +60,24 @@ impl Game {
|
|||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn current_player_id(&self) -> Option<&str> {
|
pub fn handle_throw(
|
||||||
let p = match self.turn_team {
|
&mut self,
|
||||||
Team::Red => self.red.as_ref()?,
|
team: Team,
|
||||||
Team::Yellow => self.yellow.as_ref()?,
|
broom_x: f32,
|
||||||
};
|
broom_y: f32,
|
||||||
Some(&p.id)
|
weight: u8,
|
||||||
}
|
curl: i8,
|
||||||
|
friction: f32,
|
||||||
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
|
if self.turn_team != team {
|
||||||
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
|
|
||||||
let current_id = self.current_player_id().ok_or("No current player")?;
|
|
||||||
if current_id != player_id {
|
|
||||||
return Err("Not your turn".to_string());
|
return Err("Not your turn".to_string());
|
||||||
}
|
}
|
||||||
if self.phase != GamePhase::Playing {
|
if self.phase != GamePhase::Playing {
|
||||||
return Err("Cannot throw now".to_string());
|
return Err("Cannot throw now".to_string());
|
||||||
}
|
}
|
||||||
let dx = broom_x - HOUSE_CENTER.0;
|
|
||||||
let dy = broom_y - HOUSE_CENTER.1;
|
|
||||||
let dist = (dx * dx + dy * dy).sqrt();
|
|
||||||
if dist > HOUSE_RADIUS {
|
|
||||||
return Err("Broom must be in the house".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
self.active_stones.clear();
|
self.active_stones.clear();
|
||||||
let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
let trajectory = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
||||||
self.active_stones = self.physics.current_stones();
|
self.active_stones = self.physics.current_stones();
|
||||||
self.phase = GamePhase::Simulating;
|
self.phase = GamePhase::Simulating;
|
||||||
|
|
||||||
@ -168,7 +86,35 @@ impl Game {
|
|||||||
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(path)
|
Ok(trajectory)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn process_throw(
|
||||||
|
&mut self,
|
||||||
|
team: Team,
|
||||||
|
broom_x: f32,
|
||||||
|
broom_y: f32,
|
||||||
|
weight: u8,
|
||||||
|
curl: i8,
|
||||||
|
friction: f32,
|
||||||
|
) -> Result<ThrowOutcome, String> {
|
||||||
|
let trajectory = self.handle_throw(team, broom_x, broom_y, weight, curl, friction)?;
|
||||||
|
self.finish_simulation();
|
||||||
|
|
||||||
|
let end_scored = self.take_last_end_scored();
|
||||||
|
let state_message = self.game_state_message();
|
||||||
|
let game_over = if self.phase == GamePhase::GameComplete {
|
||||||
|
Some(self.game_over_message())
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(ThrowOutcome {
|
||||||
|
trajectory,
|
||||||
|
end_scored,
|
||||||
|
state_message,
|
||||||
|
game_over,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn finish_simulation(&mut self) {
|
pub fn finish_simulation(&mut self) {
|
||||||
@ -179,10 +125,6 @@ impl Game {
|
|||||||
self.score_end_internal(false);
|
self.score_end_internal(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn score_end(&mut self) {
|
|
||||||
self.score_end_internal(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn score_end_internal(&mut self, force: bool) {
|
fn score_end_internal(&mut self, force: bool) {
|
||||||
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
|
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
|
||||||
if !end_done {
|
if !end_done {
|
||||||
@ -220,7 +162,7 @@ impl Game {
|
|||||||
Team::Red => 0,
|
Team::Red => 0,
|
||||||
Team::Yellow => 1,
|
Team::Yellow => 1,
|
||||||
};
|
};
|
||||||
self.scores[team_idx] += points as i32;
|
self.scores[team_idx] += points;
|
||||||
self.hammer = team.other();
|
self.hammer = team.other();
|
||||||
} else {
|
} else {
|
||||||
points = 0;
|
points = 0;
|
||||||
@ -229,10 +171,10 @@ impl Game {
|
|||||||
|
|
||||||
self.last_end_scored = Some((self.end, points, scoring_team));
|
self.last_end_scored = Some((self.end, points, scoring_team));
|
||||||
self.phase = GamePhase::EndComplete;
|
self.phase = GamePhase::EndComplete;
|
||||||
self.end_ends_or_continue(points, scoring_team);
|
self.advance_end_or_finish();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option<Team>) {
|
fn advance_end_or_finish(&mut self) {
|
||||||
let tied = self.scores[0] == self.scores[1];
|
let tied = self.scores[0] == self.scores[1];
|
||||||
let after_regulation = self.end >= ENDS;
|
let after_regulation = self.end >= ENDS;
|
||||||
|
|
||||||
@ -241,10 +183,6 @@ impl Game {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if after_regulation && tied {
|
|
||||||
// Extra end
|
|
||||||
}
|
|
||||||
|
|
||||||
self.end += 1;
|
self.end += 1;
|
||||||
self.stones_red = STONES_PER_TEAM;
|
self.stones_red = STONES_PER_TEAM;
|
||||||
self.stones_yellow = STONES_PER_TEAM;
|
self.stones_yellow = STONES_PER_TEAM;
|
||||||
@ -255,10 +193,6 @@ impl Game {
|
|||||||
self.phase = GamePhase::Playing;
|
self.phase = GamePhase::Playing;
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn phase(&self) -> GamePhase {
|
|
||||||
self.phase
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
|
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
|
||||||
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
|
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
|
||||||
ServerMessage::EndScored { end, points, scoring_team }
|
ServerMessage::EndScored { end, points, scoring_team }
|
||||||
@ -300,24 +234,18 @@ impl Game {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub type RoomId = String;
|
|
||||||
|
|
||||||
pub struct Room {
|
pub struct Room {
|
||||||
pub id: RoomId,
|
|
||||||
pub game: Game,
|
pub game: Game,
|
||||||
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Room {
|
impl Room {
|
||||||
pub fn new(id: &str) -> Self {
|
pub fn new(_id: &str) -> Self {
|
||||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||||
let mut room = Self {
|
Self {
|
||||||
id: id.to_string(),
|
|
||||||
game: Game::new(),
|
game: Game::new(),
|
||||||
tx,
|
tx,
|
||||||
};
|
}
|
||||||
room.game.room_tx = Some(room.tx.clone());
|
|
||||||
room
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,29 +254,46 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefers_red_when_requested_and_free() {
|
fn starts_in_waiting_phase() {
|
||||||
let mut game = Game::new();
|
let game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
assert!(matches!(game.phase, GamePhase::Waiting));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn prefers_yellow_when_requested_and_free() {
|
fn starts_when_called() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
|
game.start();
|
||||||
|
assert!(matches!(game.phase, GamePhase::Playing));
|
||||||
|
assert_eq!(game.end, 1);
|
||||||
|
assert_eq!(game.scores, [0, 0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn falls_back_when_preferred_taken() {
|
fn rejects_throw_for_wrong_team() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
game.start();
|
||||||
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
|
let turn = game.turn_team;
|
||||||
|
let wrong = turn.other();
|
||||||
|
let result = game.handle_throw(wrong, 0.5, 38.7, 7, 1, 1.0);
|
||||||
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn legacy_order_without_preference() {
|
fn accepts_throw_for_turn_team() {
|
||||||
let mut game = Game::new();
|
let mut game = Game::new();
|
||||||
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
|
game.start();
|
||||||
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
|
let turn = game.turn_team;
|
||||||
assert_eq!(game.add_player("p3".into(), None), None);
|
let result = game.handle_throw(turn, 0.5, 38.7, 7, 1, 1.0);
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn accepts_broom_outside_house() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
game.start();
|
||||||
|
let turn = game.turn_team;
|
||||||
|
// (0.0, 30.0) is well outside HOUSE_RADIUS of HOUSE_CENTER
|
||||||
|
let result = game.handle_throw(turn, 0.0, 30.0, 7, 1, 1.0);
|
||||||
|
assert!(result.is_ok(), "broom outside house should be allowed: {:?}", result.err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,30 +13,35 @@ use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
|
|||||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::Arc;
|
||||||
use tokio::sync::Mutex as TokioMutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{info, warn};
|
use tracing::{info, warn};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::game::{GamePhase, Room};
|
use crate::game::{Room, ThrowOutcome};
|
||||||
use crate::protocol::*;
|
use crate::protocol::*;
|
||||||
|
|
||||||
|
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
|
||||||
|
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct AppState {
|
struct AppState {
|
||||||
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
|
rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
fn new() -> Self {
|
fn new() -> Self {
|
||||||
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
|
Self {
|
||||||
|
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
|
async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
|
||||||
let mut rooms = self.rooms.lock().unwrap();
|
let mut rooms = self.rooms.lock().await;
|
||||||
if let Some(room) = rooms.get(room_id).cloned() {
|
if let Some(room) = rooms.get(room_id).cloned() {
|
||||||
return room;
|
return room;
|
||||||
}
|
}
|
||||||
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
|
let room = Arc::new(Mutex::new(Room::new(room_id)));
|
||||||
rooms.insert(room_id.to_string(), room.clone());
|
rooms.insert(room_id.to_string(), room.clone());
|
||||||
room
|
room
|
||||||
}
|
}
|
||||||
@ -45,7 +50,6 @@ impl AppState {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct RoomQuery {
|
struct RoomQuery {
|
||||||
room: String,
|
room: String,
|
||||||
team: Option<Team>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@ -81,7 +85,7 @@ async fn health() -> impl IntoResponse {
|
|||||||
|
|
||||||
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
let room_id = generate_room_code();
|
let room_id = generate_room_code();
|
||||||
state.get_or_create_room(&room_id);
|
state.get_or_create_room(&room_id).await;
|
||||||
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
|
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -90,53 +94,50 @@ async fn ws_handler(
|
|||||||
Query(query): Query<RoomQuery>,
|
Query(query): Query<RoomQuery>,
|
||||||
State(state): State<Arc<AppState>>,
|
State(state): State<Arc<AppState>>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
|
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String, preferred_team: Option<Team>) {
|
async fn handle_socket(
|
||||||
let room_arc = state.get_or_create_room(&room_id);
|
socket: WebSocket,
|
||||||
let player_id = Uuid::new_v4().to_string();
|
state: Arc<AppState>,
|
||||||
let player_id_for_recv = player_id.clone();
|
room_id: String,
|
||||||
|
) {
|
||||||
|
let room = state.get_or_create_room(&room_id).await;
|
||||||
|
let (mut sender, receiver) = socket.split();
|
||||||
|
|
||||||
let (mut sender, mut receiver) = socket.split();
|
|
||||||
|
|
||||||
// Reject room-full before subscribing so the error goes only to the joining socket.
|
|
||||||
{
|
{
|
||||||
let room = room_arc.lock().await;
|
let mut room_guard = room.lock().await;
|
||||||
if room.game.can_start() {
|
if matches!(room_guard.game.game_state_message(), ServerMessage::GameState { phase: Phase::Waiting, .. }) {
|
||||||
let err = serde_json::to_string(&ServerMessage::Error {
|
room_guard.game.start();
|
||||||
message: "Room is full".to_string(),
|
}
|
||||||
}).unwrap();
|
}
|
||||||
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
|
|
||||||
|
let joined = serde_json::to_string(&ServerMessage::Joined {
|
||||||
|
room: room_id.clone(),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
if sender.send(Message::Text(Utf8Bytes::from(joined))).await.is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let tx = { room.lock().await.tx.clone() };
|
||||||
|
|
||||||
|
let send_task = spawn_forwarder(sender, tx.subscribe());
|
||||||
|
broadcast_room_state(&room, &tx).await;
|
||||||
|
|
||||||
|
let recv_task = spawn_message_handler(room.clone(), tx, receiver);
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
_ = send_task => {}
|
||||||
|
_ = recv_task => {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Add the player to the room and tell only this socket its assigned team.
|
fn spawn_forwarder(
|
||||||
let team = {
|
mut sender: WsSender,
|
||||||
let mut room = room_arc.lock().await;
|
mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
|
||||||
let team = room.game.add_player(player_id.clone(), preferred_team)
|
) -> tokio::task::JoinHandle<()> {
|
||||||
.unwrap_or(Team::Red);
|
tokio::spawn(async move {
|
||||||
if room.game.can_start() {
|
|
||||||
room.game.start();
|
|
||||||
}
|
|
||||||
team
|
|
||||||
};
|
|
||||||
|
|
||||||
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
|
|
||||||
room: room_id.clone(),
|
|
||||||
team,
|
|
||||||
}).unwrap();
|
|
||||||
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
|
|
||||||
|
|
||||||
// Subscribe to broadcast and spawn the forwarding task.
|
|
||||||
let tx = {
|
|
||||||
let room = room_arc.lock().await;
|
|
||||||
room.tx.clone()
|
|
||||||
};
|
|
||||||
let mut rx = tx.subscribe();
|
|
||||||
|
|
||||||
let send_task = tokio::spawn(async move {
|
|
||||||
loop {
|
loop {
|
||||||
match rx.recv().await {
|
match rx.recv().await {
|
||||||
Ok(msg) => {
|
Ok(msg) => {
|
||||||
@ -151,49 +152,51 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
|
|||||||
Err(_) => break,
|
Err(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// Broadcast waiting/game state to everyone in the room.
|
async fn broadcast_room_state(
|
||||||
{
|
room: &Arc<Mutex<Room>>,
|
||||||
let room = room_arc.lock().await;
|
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
|
||||||
if room.game.can_start() {
|
) {
|
||||||
let state_msg = room.game.game_state_message();
|
let room_guard = room.lock().await;
|
||||||
let _ = room.tx.send(state_msg);
|
let _ = tx.send(room_guard.game.game_state_message());
|
||||||
} else {
|
}
|
||||||
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let recv_room = room_arc.clone();
|
fn spawn_message_handler(
|
||||||
let recv_id = player_id_for_recv;
|
room: Arc<Mutex<Room>>,
|
||||||
let recv_task = tokio::spawn(async move {
|
tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||||
|
mut receiver: WsReceiver,
|
||||||
|
) -> tokio::task::JoinHandle<()> {
|
||||||
|
tokio::spawn(async move {
|
||||||
while let Some(Ok(msg)) = receiver.next().await {
|
while let Some(Ok(msg)) = receiver.next().await {
|
||||||
let Message::Text(text) = msg else { continue; };
|
let Message::Text(text) = msg else { continue };
|
||||||
let text_ref = text.as_str();
|
let text_ref = text.as_str();
|
||||||
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
||||||
match parsed {
|
match parsed {
|
||||||
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
|
Ok(ClientMessage::Throw { team, broom_x, broom_y, weight, curl, friction }) => {
|
||||||
let room = recv_room.clone();
|
let mut room_guard = room.lock().await;
|
||||||
let mut room = room.lock().await;
|
match room_guard
|
||||||
if room.game.current_team_for_player(&recv_id) != Some(team) {
|
.game
|
||||||
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() });
|
.process_throw(team, broom_x, broom_y, weight, curl, friction)
|
||||||
continue;
|
{
|
||||||
|
Ok(ThrowOutcome {
|
||||||
|
trajectory,
|
||||||
|
end_scored,
|
||||||
|
state_message,
|
||||||
|
game_over,
|
||||||
|
}) => {
|
||||||
|
let _ = tx.send(ServerMessage::Trajectory { paths: trajectory });
|
||||||
|
if let Some(scored) = end_scored {
|
||||||
|
let _ = tx.send(scored);
|
||||||
}
|
}
|
||||||
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) {
|
let _ = tx.send(state_message);
|
||||||
Ok(path) => {
|
if let Some(over) = game_over {
|
||||||
room.tx.send(ServerMessage::Trajectory { path }).ok();
|
let _ = tx.send(over);
|
||||||
room.game.finish_simulation();
|
|
||||||
if let Some(scored) = room.game.take_last_end_scored() {
|
|
||||||
room.tx.send(scored).ok();
|
|
||||||
}
|
|
||||||
let after = room.game.game_state_message();
|
|
||||||
room.tx.send(after).ok();
|
|
||||||
if room.game.phase() == GamePhase::GameComplete {
|
|
||||||
room.tx.send(room.game.game_over_message()).ok();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
room.tx.send(ServerMessage::Error { message: e }).ok();
|
let _ = tx.send(ServerMessage::Error { message: e });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -202,16 +205,5 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
})
|
||||||
|
|
||||||
tokio::select! {
|
|
||||||
_ = send_task => {}
|
|
||||||
_ = recv_task => {}
|
|
||||||
}
|
|
||||||
|
|
||||||
// On disconnect, free the team slot so refreshes and new tabs can rejoin.
|
|
||||||
{
|
|
||||||
let mut room = room_arc.lock().await;
|
|
||||||
let _ = room.game.remove_player(&player_id);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -107,7 +107,7 @@ impl PhysicsWorld {
|
|||||||
weight: u8,
|
weight: u8,
|
||||||
curl: i8,
|
curl: i8,
|
||||||
friction: f32,
|
friction: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let weight = weight.clamp(1, 10) as f32;
|
let weight = weight.clamp(1, 10) as f32;
|
||||||
let t = (weight - 1.0) / 9.0;
|
let t = (weight - 1.0) / 9.0;
|
||||||
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
||||||
@ -132,7 +132,7 @@ impl PhysicsWorld {
|
|||||||
vy: f32,
|
vy: f32,
|
||||||
curl_sign: i8,
|
curl_sign: i8,
|
||||||
damping_mult: f32,
|
damping_mult: f32,
|
||||||
) -> Result<Vec<(f32, f32, f32)>, String> {
|
) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let id = self.next_stone_id;
|
let id = self.next_stone_id;
|
||||||
self.next_stone_id += 1;
|
self.next_stone_id += 1;
|
||||||
|
|
||||||
@ -161,15 +161,31 @@ impl PhysicsWorld {
|
|||||||
self.simulate_until_rest(id)
|
self.simulate_until_rest(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
|
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<StoneTrajectory>, String> {
|
||||||
let mut path: Vec<(f32, f32, f32)> = Vec::new();
|
// All paths share the thrown stone's release instant as t=0. This keeps the
|
||||||
|
// frontend's existing trajectory helpers (which expect the thrown stone to
|
||||||
|
// start at x=0, y=HACK_Y with t=0) working unchanged while also giving every
|
||||||
|
// other stone a consistent timeline.
|
||||||
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||||
let mut sample_accum: f32 = 0.0;
|
let mut sample_accum: f32 = 0.0;
|
||||||
let mut time: f32 = 0.0;
|
let mut time: f32 = 0.0;
|
||||||
|
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
// Pre-allocate a path buffer for every stone currently in the world.
|
||||||
let body = &self.bodies[*h];
|
let mut paths: Vec<(u32, RigidBodyHandle, Vec<(f32, f32, f32)>)> = self
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
.stone_handles
|
||||||
|
.iter()
|
||||||
|
.map(|(id, handle, _, _)| (*id, *handle, Vec::new()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Record the initial sample at t=0 for every stone.
|
||||||
|
for (id, handle, path) in &mut paths {
|
||||||
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
path.push((pos.x, pos.y, time));
|
||||||
|
} else {
|
||||||
|
// Body missing for an tracked stone; this should not happen.
|
||||||
|
return Err(format!("stone {} has no rigid body", id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@ -180,9 +196,11 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
if sample_accum >= sample_step {
|
if sample_accum >= sample_step {
|
||||||
sample_accum -= sample_step;
|
sample_accum -= sample_step;
|
||||||
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
for (_, handle, path) in &mut paths {
|
||||||
let body = &self.bodies[*h];
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
path.push((body.translation().x, body.translation().y, time));
|
let pos = body.translation();
|
||||||
|
path.push((pos.x, pos.y, time));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,7 +211,27 @@ impl PhysicsWorld {
|
|||||||
|
|
||||||
self.prune_out_of_play();
|
self.prune_out_of_play();
|
||||||
|
|
||||||
Ok(path)
|
// The thrown stone is released at (0.0, HACK_Y). Shift every path in time so
|
||||||
|
// that t=0 corresponds to that release instant. Because we already started
|
||||||
|
// sampling at the release instant, the first sample time is 0.0 and no shift
|
||||||
|
// is required; this comment documents the invariant.
|
||||||
|
let thrown_first_t = paths
|
||||||
|
.iter()
|
||||||
|
.find(|(id, _, _)| *id == thrown_id)
|
||||||
|
.and_then(|(_, _, path)| path.first().map(|(_, _, t)| *t))
|
||||||
|
.unwrap_or(0.0);
|
||||||
|
|
||||||
|
Ok(paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id, _, mut path)| {
|
||||||
|
if thrown_first_t != 0.0 {
|
||||||
|
for (_, _, t) in &mut path {
|
||||||
|
*t -= thrown_first_t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
StoneTrajectory { stone_id: id, path }
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rotate each stone's velocity slightly based on its selected curl direction.
|
// Rotate each stone's velocity slightly based on its selected curl direction.
|
||||||
@ -377,4 +415,70 @@ mod tests {
|
|||||||
let stones = world.current_stones();
|
let stones = world.current_stones();
|
||||||
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collision_records_trajectories_for_both_stones() {
|
||||||
|
// Place a stationary stone on the center line and throw a second stone
|
||||||
|
// straight at it so they collide. Both stones must have sampled paths.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
|
||||||
|
// First stone: place it far enough up-sheet to stay in play after impact.
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
|
// Second stone: aimed directly at the first stone's final position.
|
||||||
|
let target_y = final_y(&world, first_id);
|
||||||
|
let target_x = final_x(&world, first_id);
|
||||||
|
world
|
||||||
|
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let second_id = world.next_stone_id - 1;
|
||||||
|
|
||||||
|
// Re-run the collision throw and capture trajectories.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world
|
||||||
|
.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
let first_id = world.next_stone_id - 1;
|
||||||
|
let target_y = final_y(&world, first_id);
|
||||||
|
let target_x = final_x(&world, first_id);
|
||||||
|
let trajectories = world
|
||||||
|
.throw(Team::Yellow, target_x, target_y, 10, 0, 1.0)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let by_id: std::collections::HashMap<u32, Vec<(f32, f32, f32)>> = trajectories
|
||||||
|
.into_iter()
|
||||||
|
.map(|st| (st.stone_id, st.path))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
by_id.contains_key(&first_id),
|
||||||
|
"trajectories should contain the first stone (id={})",
|
||||||
|
first_id
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
by_id.contains_key(&second_id),
|
||||||
|
"trajectories should contain the thrown stone (id={})",
|
||||||
|
second_id
|
||||||
|
);
|
||||||
|
|
||||||
|
let first_path = by_id.get(&first_id).unwrap();
|
||||||
|
let second_path = by_id.get(&second_id).unwrap();
|
||||||
|
assert!(
|
||||||
|
first_path.len() > 1,
|
||||||
|
"first stone path should have multiple samples, got {}",
|
||||||
|
first_path.len()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
second_path.len() > 1,
|
||||||
|
"thrown stone path should have multiple samples, got {}",
|
||||||
|
second_path.len()
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both paths should share the same t=0 reference (the thrown stone's release).
|
||||||
|
assert_eq!(first_path[0].2, 0.0, "first stone path should start at t=0");
|
||||||
|
assert_eq!(second_path[0].2, 0.0, "thrown stone path should start at t=0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ use std::fmt;
|
|||||||
pub const TICK_RATE_HZ: u16 = 120;
|
pub const TICK_RATE_HZ: u16 = 120;
|
||||||
pub const SAMPLE_RATE_HZ: u16 = 40;
|
pub const SAMPLE_RATE_HZ: u16 = 40;
|
||||||
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
||||||
|
pub const FEET_TO_METERS: f32 = 0.3048;
|
||||||
|
|
||||||
pub const STONES_PER_TEAM: u8 = 8;
|
pub const STONES_PER_TEAM: u8 = 8;
|
||||||
pub const ENDS: u8 = 10;
|
pub const ENDS: u8 = 10;
|
||||||
@ -12,11 +13,7 @@ pub const ENDS: u8 = 10;
|
|||||||
pub const SHEET_WIDTH: f32 = 5.0;
|
pub const SHEET_WIDTH: f32 = 5.0;
|
||||||
pub const SHEET_LENGTH: f32 = 45.0;
|
pub const SHEET_LENGTH: f32 = 45.0;
|
||||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||||
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
|
pub const HOUSE_RADIUS: f32 = 6.0 * FEET_TO_METERS; // 12 ft diameter → 6 ft radius
|
||||||
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
|
|
||||||
pub const FOUR_FT_RADIUS: f32 = 0.6096;
|
|
||||||
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
|
|
||||||
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
|
|
||||||
pub const HOG_LINE_Y: f32 = 21.0;
|
pub const HOG_LINE_Y: f32 = 21.0;
|
||||||
pub const BACK_LINE_Y: f32 = 42.0;
|
pub const BACK_LINE_Y: f32 = 42.0;
|
||||||
pub const HACK_Y: f32 = 2.0;
|
pub const HACK_Y: f32 = 2.0;
|
||||||
@ -59,6 +56,7 @@ impl fmt::Display for Team {
|
|||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ClientMessage {
|
pub enum ClientMessage {
|
||||||
Throw {
|
Throw {
|
||||||
|
team: Team,
|
||||||
broom_x: f32,
|
broom_x: f32,
|
||||||
broom_y: f32,
|
broom_y: f32,
|
||||||
weight: u8,
|
weight: u8,
|
||||||
@ -75,7 +73,7 @@ fn default_friction() -> f32 { 1.0 }
|
|||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type", rename_all = "snake_case")]
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
pub enum ServerMessage {
|
pub enum ServerMessage {
|
||||||
Joined { room: String, team: Team },
|
Joined { room: String },
|
||||||
Waiting { message: String },
|
Waiting { message: String },
|
||||||
GameState {
|
GameState {
|
||||||
end: u8,
|
end: u8,
|
||||||
@ -86,7 +84,7 @@ pub enum ServerMessage {
|
|||||||
phase: Phase,
|
phase: Phase,
|
||||||
},
|
},
|
||||||
Trajectory {
|
Trajectory {
|
||||||
path: Vec<(f32, f32, f32)>,
|
paths: Vec<StoneTrajectory>,
|
||||||
},
|
},
|
||||||
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
||||||
GameOver {
|
GameOver {
|
||||||
@ -108,6 +106,12 @@ pub enum Phase {
|
|||||||
GameComplete,
|
GameComplete,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoneTrajectory {
|
||||||
|
pub stone_id: u32,
|
||||||
|
pub path: Vec<(f32, f32, f32)>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct StoneState {
|
pub struct StoneState {
|
||||||
pub id: u32,
|
pub id: u32,
|
||||||
@ -117,10 +121,3 @@ pub struct StoneState {
|
|||||||
pub rotation: f32,
|
pub rotation: f32,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
||||||
pub struct Player {
|
|
||||||
pub id: String,
|
|
||||||
pub team: Team,
|
|
||||||
pub connected: bool,
|
|
||||||
}
|
|
||||||
|
|||||||
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) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString())
|
const msg = JSON.parse(data.toString())
|
||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
|
if (msg.type === 'joined') resolve({ ws, messages })
|
||||||
})
|
})
|
||||||
ws.on('error', reject)
|
ws.on('error', reject)
|
||||||
})
|
})
|
||||||
@ -47,9 +47,8 @@ function waitFor(messages, pred, timeout = 30000) {
|
|||||||
}, 5000)
|
}, 5000)
|
||||||
const turn = getTurn()
|
const turn = getTurn()
|
||||||
if (!turn) throw new Error('no turn')
|
if (!turn) throw new Error('no turn')
|
||||||
const thrower = turn === p1.team ? p1 : p2
|
|
||||||
const broomX = (Math.random() - 0.5) * 0.6
|
const broomX = (Math.random() - 0.5) * 0.6
|
||||||
thrower.ws.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: broomX, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
const prevStateCount = p1.messages.filter(m => m.type === 'game_state').length
|
||||||
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
|
await waitFor(p1.messages, () => p1.messages.filter(m => m.type === 'game_state').length > prevStateCount, 15000)
|
||||||
}
|
}
|
||||||
|
|||||||
75
e2e/e2e_multi_client.cjs
Normal file
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)
|
messages.push(msg)
|
||||||
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
|
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team} stones=${msg.stones.length}` : '')
|
||||||
if (msg.type === 'joined') {
|
if (msg.type === 'joined') {
|
||||||
resolve({ ws, messages, team: msg.team })
|
resolve({ ws, messages })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ws.on('open', () => console.log(`[${name}] open`))
|
ws.on('open', () => console.log(`[${name}] open`))
|
||||||
@ -38,27 +38,23 @@ function latestState(messages) {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p2.messages.some(m => m.type === 'game_state'))
|
|
||||||
|
|
||||||
// Determine the current player and throw two stones in the same end.
|
// Determine the current player and throw two stones in the same end.
|
||||||
let state = latestState(p1.messages)
|
let state = latestState(p1.messages)
|
||||||
console.log('Initial state', state)
|
console.log('Initial state', state)
|
||||||
|
|
||||||
// First throw: yellow aims slightly left of center.
|
// First throw: current turn team.
|
||||||
let turn = state.turn_team
|
let turn = state.turn_team
|
||||||
let turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.3, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 1, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
console.log('After first throw:', state)
|
console.log('After first throw:', state)
|
||||||
|
|
||||||
// Second throw: red aims slightly right of center.
|
// Second throw: other team.
|
||||||
turn = state.turn_team
|
turn = state.turn_team
|
||||||
turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.3, broom_y: 39, weight: 7, curl: -1, friction: 1.0 }))
|
|
||||||
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
await waitFor(() => latestState(p1.messages)?.stones?.length === 2, 15000)
|
||||||
|
|
||||||
state = latestState(p1.messages)
|
state = latestState(p1.messages)
|
||||||
@ -77,7 +73,6 @@ function latestState(messages) {
|
|||||||
|
|
||||||
console.log('PERSISTENCE E2E PASSED')
|
console.log('PERSISTENCE E2E PASSED')
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
@ -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) => {
|
ws.on('message', (data) => {
|
||||||
const msg = JSON.parse(data.toString())
|
const msg = JSON.parse(data.toString())
|
||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
if (msg.type === 'joined') resolve({ ws, messages, team: msg.team })
|
if (msg.type === 'joined') resolve({ ws, messages })
|
||||||
})
|
})
|
||||||
ws.on('error', reject)
|
ws.on('error', reject)
|
||||||
})
|
})
|
||||||
@ -29,26 +29,24 @@ function waitFor(condFn, timeout = 5000) {
|
|||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
let state = p1.messages.find(m => m.type === 'game_state')
|
let state = p1.messages.find(m => m.type === 'game_state')
|
||||||
console.log('start turn', state.turn_team, 'hammer', state.hammer)
|
console.log('start turn', state.turn_team, 'hammer', state.hammer)
|
||||||
|
|
||||||
const thrower1 = state.turn_team === p1.team ? p1 : p2
|
const thrower1 = state.turn_team
|
||||||
thrower1.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower1, broom_x: 0.2, broom_y: 38.7, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
state = p1.messages.slice(-1)[0]
|
state = p1.messages.slice(-1)[0]
|
||||||
console.log('After first throw:', state)
|
console.log('After first throw:', state)
|
||||||
const thrower2 = state.turn_team === p1.team ? p1 : p2
|
const thrower2 = state.turn_team
|
||||||
thrower2.ws.send(JSON.stringify({ type: 'throw', broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
|
p1.ws.send(JSON.stringify({ type: 'throw', team: thrower2, broom_x: -0.1, broom_y: 38.8, weight: 9, curl: 1, friction: 1.0 }))
|
||||||
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
|
await waitFor(() => p1.messages.filter(m => m.type === 'trajectory').length >= 2, 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
await new Promise(r => setTimeout(r, 500))
|
await new Promise(r => setTimeout(r, 500))
|
||||||
console.log('Final stones', p1.messages.slice(-1)[0].stones)
|
console.log('Final stones', p1.messages.slice(-1)[0].stones)
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(e => {
|
})().catch(e => {
|
||||||
console.error(e)
|
console.error(e)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ function connect(name) {
|
|||||||
messages.push(msg)
|
messages.push(msg)
|
||||||
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '')
|
console.log(`[${name}]`, msg.type, msg.type === 'game_state' ? ` turn=${msg.turn_team}` : '')
|
||||||
if (msg.type === 'joined') {
|
if (msg.type === 'joined') {
|
||||||
resolve({ ws, messages, team: msg.team })
|
resolve({ ws, messages })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
ws.on('open', () => console.log(`[${name}] open`))
|
ws.on('open', () => console.log(`[${name}] open`))
|
||||||
@ -21,31 +21,30 @@ function connect(name) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function waitFor(condFn, timeout = 5000) {
|
function waitFor(condFn, timeout = 5000) {
|
||||||
const start = Date.now()
|
const start = Date.now()
|
||||||
while (!condFn()) {
|
return new Promise((resolve, reject) => {
|
||||||
if (Date.now() - start > timeout) throw new Error('Timeout waiting')
|
const check = () => {
|
||||||
await new Promise(r => setTimeout(r, 50))
|
if (condFn()) return resolve(undefined)
|
||||||
|
if (Date.now() - start > timeout) return reject(new Error('Timeout waiting'))
|
||||||
|
setTimeout(check, 50)
|
||||||
}
|
}
|
||||||
|
check()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
;(async () => {
|
;(async () => {
|
||||||
const p1 = await connect('p1')
|
const p1 = await connect('p1')
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'waiting'))
|
|
||||||
const p2 = await connect('p2')
|
|
||||||
await waitFor(() => p2.messages.some(m => m.type === 'joined'))
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
await waitFor(() => p1.messages.some(m => m.type === 'game_state'))
|
||||||
const state = p1.messages.find(m => m.type === 'game_state')
|
const state = p1.messages.find(m => m.type === 'game_state')
|
||||||
console.log('Game state', state)
|
console.log('Game state', state)
|
||||||
|
|
||||||
const turn = state.turn_team
|
const turn = state.turn_team
|
||||||
const turnPlayer = turn === p1.team ? p1 : p2
|
p1.ws.send(JSON.stringify({ type: 'throw', team: turn, broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
||||||
turnPlayer.ws.send(JSON.stringify({ type: 'throw', broom_x: 0.5, broom_y: 39, weight: 7, curl: 1, friction: 1.0 }))
|
|
||||||
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
await waitFor(() => p1.messages.some(m => m.type === 'trajectory'), 15000)
|
||||||
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
await waitFor(() => p1.messages.slice(-1)[0]?.type === 'game_state', 15000)
|
||||||
console.log('Final state after throw:', p1.messages.slice(-1)[0])
|
console.log('Final state after throw:', p1.messages.slice(-1)[0])
|
||||||
p1.ws.close()
|
p1.ws.close()
|
||||||
p2.ws.close()
|
|
||||||
process.exit(0)
|
process.exit(0)
|
||||||
})().catch(err => {
|
})().catch(err => {
|
||||||
console.error(err)
|
console.error(err)
|
||||||
|
|||||||
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 { connect, sendThrow, type NetCallbacks } from './net'
|
||||||
import { createRenderer } from './renderer'
|
import { createRenderer } from './renderer'
|
||||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
|
import { type Team } from './protocol'
|
||||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
import { GameModel } from './game-model'
|
||||||
|
|
||||||
interface GameState {
|
|
||||||
end: number
|
|
||||||
scores: number[]
|
|
||||||
hammer: Team
|
|
||||||
turnTeam: Team
|
|
||||||
myTeam: Team | null
|
|
||||||
phase: Phase
|
|
||||||
stones: StoneState[]
|
|
||||||
animating: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startGame(): void {
|
export function startGame(): void {
|
||||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||||
|
eros
commented
move physics parameters (friction, curl) to a dropdown menu move physics parameters (friction, curl) to a dropdown menu
|
|||||||
@ -24,6 +13,10 @@ export function startGame(): void {
|
|||||||
const hud = createHud()
|
const hud = createHud()
|
||||||
app.appendChild(hud.root)
|
app.appendChild(hud.root)
|
||||||
|
|
||||||
|
if (import.meta.env.DEV) {
|
||||||
|
;(window as unknown as { __renderer: typeof renderer }).__renderer = renderer
|
||||||
|
}
|
||||||
|
|
||||||
const velocityContainer = hud.velocityControl
|
const velocityContainer = hud.velocityControl
|
||||||
const curlContainer = hud.curlSelector
|
const curlContainer = hud.curlSelector
|
||||||
const frictionContainer = hud.frictionControl
|
const frictionContainer = hud.frictionControl
|
||||||
@ -42,141 +35,65 @@ export function startGame(): void {
|
|||||||
const shareLink = `${window.location.origin}/?room=${room}`
|
const shareLink = `${window.location.origin}/?room=${room}`
|
||||||
hud.setShareLink(shareLink)
|
hud.setShareLink(shareLink)
|
||||||
|
|
||||||
const picker = document.createElement('div')
|
const model = new GameModel()
|
||||||
picker.id = 'team-picker'
|
|
||||||
picker.innerHTML = `
|
|
||||||
<div class="team-picker-box">
|
|
||||||
<h2>Choose your team</h2>
|
|
||||||
<div class="team-picker-buttons">
|
|
||||||
<button class="team-btn team-red" data-team="red">Play as Red</button>
|
|
||||||
<button class="team-btn team-yellow" data-team="yellow">Play as Yellow</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
app.appendChild(picker)
|
|
||||||
|
|
||||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
const initialTeam: Team = localStorage.getItem('curltastic-team') === 'yellow' ? 'yellow' : 'red'
|
||||||
btn.addEventListener('click', () => {
|
model.setMyTeam(initialTeam)
|
||||||
const team = btn.dataset.team as Team
|
hud.setTeam(initialTeam)
|
||||||
picker.remove()
|
|
||||||
connect(room, team, callbacks)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
let gameState: GameState = {
|
// connect() is invoked after callbacks is defined below.
|
||||||
end: 1,
|
|
||||||
scores: [0, 0],
|
|
||||||
hammer: 'red',
|
|
||||||
turnTeam: 'red',
|
|
||||||
myTeam: null,
|
|
||||||
phase: 'waiting',
|
|
||||||
stones: [],
|
|
||||||
animating: false,
|
|
||||||
}
|
|
||||||
let pendingStones: StoneState[] = []
|
|
||||||
|
|
||||||
let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
|
|
||||||
let isDragging = false
|
|
||||||
let activePath: [number, number, number][] = []
|
|
||||||
let animationStartTime = 0
|
|
||||||
let animationTeam: Team = 'red'
|
|
||||||
|
|
||||||
const updateControls = () => {
|
const updateControls = () => {
|
||||||
const myTurn = Boolean(
|
const activeTeam = hud.teamSelect.value as Team
|
||||||
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
|
model.setMyTeam(activeTeam)
|
||||||
)
|
const myTurn = model.isMyTurn
|
||||||
velocity.setEnabled(myTurn && !gameState.animating)
|
velocity.setEnabled(myTurn)
|
||||||
curls.setEnabled(myTurn && !gameState.animating)
|
curls.setEnabled(myTurn)
|
||||||
friction.setEnabled(myTurn && !gameState.animating)
|
friction.setEnabled(myTurn)
|
||||||
throwBtn.disabled = !myTurn || gameState.animating
|
throwBtn.disabled = !myTurn
|
||||||
if (gameState.phase === 'simulating' || gameState.animating) {
|
hud.update(model.state)
|
||||||
velocity.setEnabled(false)
|
|
||||||
curls.setEnabled(false)
|
|
||||||
friction.setEnabled(false)
|
|
||||||
}
|
|
||||||
hud.update(gameState)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const render = () => {
|
hud.teamSelect.addEventListener('change', () => {
|
||||||
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
|
localStorage.setItem('curltastic-team', hud.teamSelect.value)
|
||||||
if (gameState.animating && activePath.length > 1) {
|
updateControls()
|
||||||
const elapsed = (performance.now() - animationStartTime) / 1000
|
})
|
||||||
const total = activePath[activePath.length - 1][2]
|
|
||||||
if (elapsed >= total) {
|
const render = () => {
|
||||||
gameState.animating = false
|
const wasAnimating = model.state.animating
|
||||||
if (pendingStones.length > 0) {
|
const activeStonePos = model.tick(performance.now())
|
||||||
gameState.stones = pendingStones
|
if (wasAnimating && !model.state.animating) {
|
||||||
pendingStones = []
|
|
||||||
}
|
|
||||||
updateControls()
|
updateControls()
|
||||||
} else {
|
|
||||||
let i = 0
|
|
||||||
while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++
|
|
||||||
const p0 = activePath[i]
|
|
||||||
const p1 = activePath[i + 1] ?? p0
|
|
||||||
const t0 = activePath[Math.max(i - 1, 0)]
|
|
||||||
const t2 = activePath[Math.min(i + 2, activePath.length - 1)]
|
|
||||||
const dt = p1[2] - p0[2]
|
|
||||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
|
||||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
|
||||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
|
||||||
const dx = t2[0] - t0[0]
|
|
||||||
const dy = t2[1] - t0[1]
|
|
||||||
const rotation = Math.atan2(dy, dx) * 2
|
|
||||||
activeStonePos = { x, y, rotation, team: animationTeam }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderer.draw({
|
renderer.draw({
|
||||||
stones: gameState.stones,
|
stones: model.state.stones,
|
||||||
broom:
|
broom:
|
||||||
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
|
model.state.phase === 'playing' &&
|
||||||
? broom
|
!model.state.animating &&
|
||||||
|
model.state.myTeam === model.state.turnTeam
|
||||||
|
? model.broom
|
||||||
: null,
|
: null,
|
||||||
animating: gameState.animating,
|
animating: model.state.animating,
|
||||||
activeStonePos,
|
activeStonePos,
|
||||||
})
|
})
|
||||||
requestAnimationFrame(render)
|
requestAnimationFrame(render)
|
||||||
}
|
}
|
||||||
|
|
||||||
const callbacks: NetCallbacks = {
|
const callbacks: NetCallbacks = {
|
||||||
onJoined: (_roomId, team) => {
|
onJoined: () => {
|
||||||
gameState.myTeam = team
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onWaiting: () => {
|
onWaiting: () => {
|
||||||
gameState.phase = 'waiting'
|
model.setWaiting()
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onGameState: (msg) => {
|
onGameState: (msg) => {
|
||||||
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
|
model.updateGameState(msg)
|
||||||
if (reset) {
|
|
||||||
gameState.stones = []
|
|
||||||
pendingStones = []
|
|
||||||
}
|
|
||||||
|
|
||||||
if (gameState.animating) {
|
|
||||||
pendingStones = msg.stones
|
|
||||||
} else {
|
|
||||||
gameState.stones = msg.stones
|
|
||||||
}
|
|
||||||
|
|
||||||
gameState = {
|
|
||||||
...gameState,
|
|
||||||
end: msg.end,
|
|
||||||
scores: msg.scores,
|
|
||||||
hammer: msg.hammer,
|
|
||||||
turnTeam: msg.turn_team,
|
|
||||||
phase: msg.phase,
|
|
||||||
}
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onTrajectory: (path) => {
|
onTrajectory: (paths) => {
|
||||||
activePath = trimPathToStartAtHogLine(path)
|
model.startTrajectory(paths)
|
||||||
gameState.animating = activePath.length > 1
|
|
||||||
animationStartTime = performance.now()
|
|
||||||
animationTeam = gameState.turnTeam
|
|
||||||
pendingStones = []
|
|
||||||
updateControls()
|
updateControls()
|
||||||
},
|
},
|
||||||
onEndScored: (end, points, scoringTeam) => {
|
onEndScored: (end, points, scoringTeam) => {
|
||||||
@ -206,45 +123,45 @@ export function startGame(): void {
|
|||||||
return { x: e.clientX, y: e.clientY }
|
return { x: e.clientX, y: e.clientY }
|
||||||
}
|
}
|
||||||
|
|
||||||
const constrainBroom = (world: { x: number; y: number }) => {
|
let lastPanScreenY = 0
|
||||||
const dx = world.x - HOUSE_CENTER.x
|
|
||||||
const dy = world.y - HOUSE_CENTER.y
|
|
||||||
const dist = Math.sqrt(dx * dx + dy * dy)
|
|
||||||
if (dist > HOUSE_RADIUS) {
|
|
||||||
const ratio = HOUSE_RADIUS / dist
|
|
||||||
return {
|
|
||||||
x: HOUSE_CENTER.x + dx * ratio,
|
|
||||||
y: HOUSE_CENTER.y + dy * ratio,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return world
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleStart = (e: Event) => {
|
const handleStart = (e: Event) => {
|
||||||
if (!isMyDragTurn()) return
|
|
||||||
isDragging = true
|
|
||||||
const pos = getPos(e as TouchEvent)
|
const pos = getPos(e as TouchEvent)
|
||||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
const world = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
|
||||||
|
if (model.isMyTurn) {
|
||||||
|
model.isDragging = true
|
||||||
|
model.broom = world
|
||||||
|
} else {
|
||||||
|
model.isPanning = true
|
||||||
|
lastPanScreenY = pos.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleMove = (e: Event) => {
|
const handleMove = (e: Event) => {
|
||||||
if (!isDragging || !isMyDragTurn()) return
|
if (model.isDragging && model.isMyTurn) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const pos = getPos(e as TouchEvent)
|
const pos = getPos(e as TouchEvent)
|
||||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
model.broom = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (model.isPanning) {
|
||||||
|
e.preventDefault()
|
||||||
|
const pos = getPos(e as TouchEvent)
|
||||||
|
const prevWorld = renderer.screenToWorld(pos.x, lastPanScreenY)
|
||||||
|
const nowWorld = renderer.screenToWorld(pos.x, pos.y)
|
||||||
|
// Direct-manipulation pan: dragging the sheet down reveals the hog line above.
|
||||||
|
const deltaY = nowWorld.y - prevWorld.y
|
||||||
|
const sensitivity = 1.5
|
||||||
|
renderer.setViewYOffset(renderer.clampViewYOffset() + deltaY * sensitivity)
|
||||||
|
lastPanScreenY = pos.y
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleEnd = () => {
|
const handleEnd = () => {
|
||||||
isDragging = false
|
model.isDragging = false
|
||||||
}
|
model.isPanning = false
|
||||||
|
|
||||||
const isMyDragTurn = () => {
|
|
||||||
return (
|
|
||||||
gameState.myTeam !== null &&
|
|
||||||
gameState.turnTeam === gameState.myTeam &&
|
|
||||||
gameState.phase === 'playing' &&
|
|
||||||
!gameState.animating
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||||
@ -256,10 +173,18 @@ export function startGame(): void {
|
|||||||
canvas.addEventListener('mouseleave', handleEnd)
|
canvas.addEventListener('mouseleave', handleEnd)
|
||||||
|
|
||||||
throwBtn.addEventListener('click', () => {
|
throwBtn.addEventListener('click', () => {
|
||||||
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
|
if (!model.isMyTurn) return
|
||||||
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
sendThrow(
|
||||||
|
hud.teamSelect.value as Team,
|
||||||
|
model.broom.x,
|
||||||
|
model.broom.y,
|
||||||
|
velocity.getWeight(),
|
||||||
|
curls.getSelected(),
|
||||||
|
friction.getFriction(),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
connect(room, callbacks)
|
||||||
updateControls()
|
updateControls()
|
||||||
render()
|
render()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,17 +3,18 @@ import { velocityToWeight, weightToVelocity } from './game-helpers'
|
|||||||
|
|
||||||
export interface Hud {
|
export interface Hud {
|
||||||
root: HTMLDivElement
|
root: HTMLDivElement
|
||||||
|
teamSelect: HTMLSelectElement
|
||||||
velocityControl: HTMLDivElement
|
velocityControl: HTMLDivElement
|
||||||
curlSelector: HTMLDivElement
|
curlSelector: HTMLDivElement
|
||||||
frictionControl: HTMLDivElement
|
frictionControl: HTMLDivElement
|
||||||
throwButton: HTMLButtonElement
|
throwButton: HTMLButtonElement
|
||||||
|
setTeam: (team: Team) => void
|
||||||
update: (state: {
|
update: (state: {
|
||||||
phase: Phase
|
phase: Phase
|
||||||
end: number
|
end: number
|
||||||
scores: number[]
|
scores: number[]
|
||||||
hammer: Team
|
hammer: Team
|
||||||
turnTeam: Team
|
turnTeam: Team
|
||||||
myTeam: Team | null
|
|
||||||
animating: boolean
|
animating: boolean
|
||||||
}) => void
|
}) => void
|
||||||
showToast: (message: string) => void
|
showToast: (message: string) => void
|
||||||
@ -48,12 +49,20 @@ export function createHud(): Hud {
|
|||||||
const root = document.createElement('div')
|
const root = document.createElement('div')
|
||||||
root.id = 'hud'
|
root.id = 'hud'
|
||||||
root.innerHTML = `
|
root.innerHTML = `
|
||||||
|
<div id="hud-top-group">
|
||||||
|
<div class="hud-row" id="share-row">
|
||||||
|
<div id="share"><button>Copy share link</button></div>
|
||||||
|
</div>
|
||||||
<div class="hud-row">
|
<div class="hud-row">
|
||||||
<div id="score">Red 0 - Yellow 0</div>
|
<div id="score">Red 0 - Yellow 0</div>
|
||||||
<div id="end-info">End 1 · Waiting</div>
|
<div id="end-info">End 1 · Waiting</div>
|
||||||
<div id="team">You: -</div>
|
<select id="team-select" aria-label="Team">
|
||||||
|
<option value="red">Red</option>
|
||||||
|
<option value="yellow">Yellow</option>
|
||||||
|
</select>
|
||||||
<div id="hammer">Hammer: -</div>
|
<div id="hammer">Hammer: -</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="hud-row" style="align-items:flex-end;">
|
<div class="hud-row" style="align-items:flex-end;">
|
||||||
<div id="velocity-control"></div>
|
<div id="velocity-control"></div>
|
||||||
<div id="curl-selector"></div>
|
<div id="curl-selector"></div>
|
||||||
@ -66,34 +75,32 @@ export function createHud(): Hud {
|
|||||||
<button id="throw-btn" disabled>THROW</button>
|
<button id="throw-btn" disabled>THROW</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="waiting">Waiting for other player</div>
|
<div id="waiting">Waiting</div>
|
||||||
<div id="share"><button>Copy share link</button></div>
|
|
||||||
`
|
`
|
||||||
|
|
||||||
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
const scoreEl = root.querySelector<HTMLDivElement>('#score')!
|
||||||
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
const endInfoEl = root.querySelector<HTMLDivElement>('#end-info')!
|
||||||
const teamEl = root.querySelector<HTMLDivElement>('#team')!
|
const teamSelect = root.querySelector<HTMLSelectElement>('#team-select')!
|
||||||
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
const hammerEl = root.querySelector<HTMLDivElement>('#hammer')!
|
||||||
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
const waitingEl = root.querySelector<HTMLDivElement>('#waiting')!
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
|
teamSelect,
|
||||||
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
velocityControl: root.querySelector<HTMLDivElement>('#velocity-control')!,
|
||||||
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
curlSelector: root.querySelector<HTMLDivElement>('#curl-selector')!,
|
||||||
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
frictionControl: root.querySelector<HTMLDivElement>('#friction-control')!,
|
||||||
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
throwButton: root.querySelector<HTMLButtonElement>('#throw-btn')!,
|
||||||
|
setTeam: (team) => {
|
||||||
|
teamSelect.value = team
|
||||||
|
},
|
||||||
update: (state) => {
|
update: (state) => {
|
||||||
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
scoreEl.textContent = `Red ${state.scores[0] ?? 0} - Yellow ${state.scores[1] ?? 0}`
|
||||||
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
const teamNames: Record<Team, string> = { red: 'Red', yellow: 'Yellow' }
|
||||||
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
const phaseText = state.phase === 'playing' ? `${teamNames[state.turnTeam]}'s turn` : state.phase.replace(/_/g, ' ')
|
||||||
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
endInfoEl.textContent = `End ${state.end} · ${phaseText}`
|
||||||
teamEl.textContent = state.myTeam ? `You: ${teamNames[state.myTeam]}` : 'You: -'
|
|
||||||
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
hammerEl.textContent = `Hammer: ${teamNames[state.hammer]}`
|
||||||
const waiting =
|
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && state.animating)
|
||||||
state.phase === 'waiting' ||
|
|
||||||
(state.phase === 'playing' && state.myTeam !== null && state.turnTeam !== state.myTeam) ||
|
|
||||||
state.animating
|
|
||||||
waitingEl.classList.toggle('visible', state.phase !== 'game_complete' && waiting)
|
|
||||||
},
|
},
|
||||||
showToast: (message: string) => {
|
showToast: (message: string) => {
|
||||||
const toast = document.createElement('div')
|
const toast = document.createElement('div')
|
||||||
@ -179,14 +186,15 @@ export function createCurlSelector(
|
|||||||
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
): { getSelected: () => number; setEnabled: (enabled: boolean) => void } {
|
||||||
const state = { selected: 1, enabled: true }
|
const state = { selected: 1, enabled: true }
|
||||||
const options = [
|
const options = [
|
||||||
{ value: -1, label: '↶ Left' },
|
{ value: -1, label: '↷', ariaLabel: 'Left curl' },
|
||||||
{ value: 1, label: 'Right ↷' },
|
{ value: 1, label: '↶', ariaLabel: 'Right curl' },
|
||||||
]
|
]
|
||||||
container.innerHTML = ''
|
container.innerHTML = ''
|
||||||
for (const opt of options) {
|
for (const opt of options) {
|
||||||
const btn = document.createElement('button')
|
const btn = document.createElement('button')
|
||||||
btn.className = 'curl-btn'
|
btn.className = 'curl-btn'
|
||||||
btn.textContent = opt.label
|
btn.textContent = opt.label
|
||||||
|
btn.ariaLabel = opt.ariaLabel
|
||||||
btn.dataset.curl = String(opt.value)
|
btn.dataset.curl = String(opt.value)
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
if (!state.enabled) return
|
if (!state.enabled) return
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import type {
|
import type {
|
||||||
ServerGameStateMessage,
|
ServerGameStateMessage,
|
||||||
|
ServerStoneTrajectory,
|
||||||
ServerMessageTyped as ServerMessage,
|
ServerMessageTyped as ServerMessage,
|
||||||
Team,
|
Team,
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
@ -13,10 +14,10 @@ function resolveWsUrl(): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface NetCallbacks {
|
export interface NetCallbacks {
|
||||||
onJoined: (room: string, team: Team) => void
|
onJoined: (room: string) => void
|
||||||
onWaiting: (message: string) => void
|
onWaiting: (message: string) => void
|
||||||
onGameState: (msg: ServerGameStateMessage) => void
|
onGameState: (msg: ServerGameStateMessage) => void
|
||||||
onTrajectory: (path: [number, number, number][]) => void
|
onTrajectory: (paths: ServerStoneTrajectory[]) => void
|
||||||
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
onEndScored: (end: number, points: number, scoringTeam: Team | null) => void
|
||||||
onGameOver: (scores: number[], winner: Team | null) => void
|
onGameOver: (scores: number[], winner: Team | null) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
@ -25,10 +26,9 @@ export interface NetCallbacks {
|
|||||||
|
|
||||||
let socket: WebSocket | null = null
|
let socket: WebSocket | null = null
|
||||||
|
|
||||||
export function connect(room: string, team: Team | null, callbacks: NetCallbacks): void {
|
export function connect(room: string, callbacks: NetCallbacks): void {
|
||||||
if (socket) return
|
if (socket) return
|
||||||
const teamParam = team ? `&team=${encodeURIComponent(team)}` : ''
|
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}`
|
||||||
const url = `${resolveWsUrl()}?room=${encodeURIComponent(room)}${teamParam}`
|
|
||||||
const ws = new WebSocket(url)
|
const ws = new WebSocket(url)
|
||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
@ -42,7 +42,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
|
|
||||||
switch (msg.type) {
|
switch (msg.type) {
|
||||||
case 'joined':
|
case 'joined':
|
||||||
callbacks.onJoined(msg.room, msg.team)
|
callbacks.onJoined(msg.room)
|
||||||
break
|
break
|
||||||
case 'waiting':
|
case 'waiting':
|
||||||
callbacks.onWaiting(msg.message)
|
callbacks.onWaiting(msg.message)
|
||||||
@ -51,7 +51,7 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
callbacks.onGameState(msg)
|
callbacks.onGameState(msg)
|
||||||
break
|
break
|
||||||
case 'trajectory':
|
case 'trajectory':
|
||||||
callbacks.onTrajectory(msg.path)
|
callbacks.onTrajectory(msg.paths)
|
||||||
break
|
break
|
||||||
case 'end_scored':
|
case 'end_scored':
|
||||||
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
callbacks.onEndScored(msg.end, msg.points, msg.scoring_team ?? null)
|
||||||
@ -76,7 +76,24 @@ export function connect(room: string, team: Team | null, callbacks: NetCallbacks
|
|||||||
ws.onerror = () => {}
|
ws.onerror = () => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendThrow(broomX: number, broomY: number, weight: number, curl: number, friction: number): void {
|
export function sendThrow(
|
||||||
|
team: Team,
|
||||||
|
broomX: number,
|
||||||
|
broomY: number,
|
||||||
|
weight: number,
|
||||||
|
curl: number,
|
||||||
|
friction: number,
|
||||||
|
): void {
|
||||||
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
if (!socket || socket.readyState !== WebSocket.OPEN) return
|
||||||
socket.send(JSON.stringify({ type: 'throw', broom_x: broomX, broom_y: broomY, weight, curl, friction }))
|
socket.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: 'throw',
|
||||||
|
team,
|
||||||
|
broom_x: broomX,
|
||||||
|
broom_y: broomY,
|
||||||
|
weight,
|
||||||
|
curl,
|
||||||
|
friction,
|
||||||
|
}),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,12 +4,13 @@ export const STONES_PER_TEAM = 8
|
|||||||
export const ENDS = 10
|
export const ENDS = 10
|
||||||
export const SHEET_WIDTH = 5.0
|
export const SHEET_WIDTH = 5.0
|
||||||
export const SHEET_LENGTH = 45.0
|
export const SHEET_LENGTH = 45.0
|
||||||
|
export const FEET_TO_METERS = 0.3048
|
||||||
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
export const HOUSE_CENTER = { x: 0, y: 38.5 }
|
||||||
export const HOUSE_RADIUS = 1.83
|
export const HOUSE_RADIUS = 6 * FEET_TO_METERS
|
||||||
export const BUTTON_RADIUS = 0.1524
|
export const BUTTON_RADIUS = 0.5 * FEET_TO_METERS
|
||||||
export const FOUR_FT_RADIUS = 0.6096
|
export const FOUR_FT_RADIUS = 2 * FEET_TO_METERS
|
||||||
export const EIGHT_FT_RADIUS = 1.2192
|
export const EIGHT_FT_RADIUS = 4 * FEET_TO_METERS
|
||||||
export const TWELVE_FT_RADIUS = 1.8288
|
export const TWELVE_FT_RADIUS = 6 * FEET_TO_METERS
|
||||||
export const HOG_LINE_Y = 21.0
|
export const HOG_LINE_Y = 21.0
|
||||||
export const BACK_LINE_Y = 42.0
|
export const BACK_LINE_Y = 42.0
|
||||||
export const HACK_Y = 2.0
|
export const HACK_Y = 2.0
|
||||||
@ -26,8 +27,16 @@ export interface StoneState {
|
|||||||
active: boolean
|
active: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DrawableStone {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
rotation: number
|
||||||
|
team: Team
|
||||||
|
}
|
||||||
|
|
||||||
export interface ClientThrowMessage {
|
export interface ClientThrowMessage {
|
||||||
type: 'throw'
|
type: 'throw'
|
||||||
|
eros
commented
stone trajectory data structucture should be called trajectories with shape something like {stones: [{stoneid, rotation, team, trajectory:[x,y,theta]}]) same on the backend stone trajectory data structucture should be called trajectories with shape something like {stones: [{stoneid, rotation, team, trajectory:[x,y,theta]}])
same on the backend
|
|||||||
|
team: Team
|
||||||
broom_x: number
|
broom_x: number
|
||||||
broom_y: number
|
broom_y: number
|
||||||
weight: number
|
weight: number
|
||||||
@ -38,7 +47,6 @@ export interface ClientThrowMessage {
|
|||||||
export interface ServerJoinedMessage {
|
export interface ServerJoinedMessage {
|
||||||
type: 'joined'
|
type: 'joined'
|
||||||
room: string
|
room: string
|
||||||
team: Team
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerWaitingMessage {
|
export interface ServerWaitingMessage {
|
||||||
@ -56,9 +64,14 @@ export interface ServerGameStateMessage {
|
|||||||
phase: Phase
|
phase: Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ServerStoneTrajectory {
|
||||||
|
stone_id: number
|
||||||
|
path: [number, number, number][]
|
||||||
|
}
|
||||||
|
|
||||||
export interface ServerTrajectoryMessage {
|
export interface ServerTrajectoryMessage {
|
||||||
type: 'trajectory'
|
type: 'trajectory'
|
||||||
path: [number, number, number][]
|
paths: ServerStoneTrajectory[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ServerEndScoredMessage {
|
export interface ServerEndScoredMessage {
|
||||||
|
|||||||
@ -9,8 +9,8 @@ import {
|
|||||||
STONE_RADIUS,
|
STONE_RADIUS,
|
||||||
TWELVE_FT_RADIUS,
|
TWELVE_FT_RADIUS,
|
||||||
HOUSE_CENTER,
|
HOUSE_CENTER,
|
||||||
|
type DrawableStone,
|
||||||
type StoneState,
|
type StoneState,
|
||||||
type Team,
|
|
||||||
} from './protocol'
|
} from './protocol'
|
||||||
|
|
||||||
export interface Renderer {
|
export interface Renderer {
|
||||||
@ -19,19 +19,31 @@ export interface Renderer {
|
|||||||
setSize: () => void
|
setSize: () => void
|
||||||
worldToScreen: (x: number, y: number) => { x: number; y: number }
|
worldToScreen: (x: number, y: number) => { x: number; y: number }
|
||||||
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
|
screenToWorld: (sx: number, sy: number) => { x: number; y: number }
|
||||||
|
setViewYOffset: (y: number) => void
|
||||||
|
clampViewYOffset: () => number
|
||||||
draw: (state: {
|
draw: (state: {
|
||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
broom: { x: number; y: number } | null
|
broom: { x: number; y: number } | null
|
||||||
animating: boolean
|
animating: boolean
|
||||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
activeStonePos: DrawableStone[]
|
||||||
}) => void
|
}) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||||
const ctx = canvas.getContext('2d')!
|
const ctx = canvas.getContext('2d')!
|
||||||
// Show the area from the hog line (top) to the back line (bottom).
|
// Fit full sheet width (5 m) with a little side padding so both sidelines show.
|
||||||
const viewportHeight = BACK_LINE_Y - HOG_LINE_Y
|
// Scale is primarily width-based, but also capped so house + backline fit above the HUD.
|
||||||
const viewportCenter = { x: 0, y: (HOG_LINE_Y + BACK_LINE_Y) / 2 }
|
const SIDE_PADDING_M = 0.25
|
||||||
|
const viewportWidth = SHEET_WIDTH + SIDE_PADDING_M * 2
|
||||||
|
// Min world height: top of 12ft ring through backline + small pad.
|
||||||
|
const MIN_VIEW_HEIGHT_M =
|
||||||
|
BACK_LINE_Y - (HOUSE_CENTER.y - TWELVE_FT_RADIUS) + SIDE_PADDING_M
|
||||||
|
// Throw button (80) + hud padding (12×2) + small gap above controls.
|
||||||
|
const BOTTOM_UI_PX = 120
|
||||||
|
const viewportCenter = { x: 0, y: HOUSE_CENTER.y }
|
||||||
|
// Prefer backline at the bottom of the usable ice area (just above HUD).
|
||||||
|
// First setSize()/setViewYOffset clamps this to the valid minOffset.
|
||||||
|
let viewportYOffset = -(BACK_LINE_Y - HOUSE_CENTER.y)
|
||||||
|
|
||||||
const setSize = () => {
|
const setSize = () => {
|
||||||
const dpr = window.devicePixelRatio || 1
|
const dpr = window.devicePixelRatio || 1
|
||||||
@ -42,33 +54,75 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
canvas.style.width = `${width}px`
|
canvas.style.width = `${width}px`
|
||||||
canvas.style.height = `${height}px`
|
canvas.style.height = `${height}px`
|
||||||
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
|
||||||
|
// Re-clamp so default framing keeps the backline just above the HUD.
|
||||||
|
setViewYOffset(viewportYOffset)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Screen height available for ice (above bottom controls). */
|
||||||
|
const usableHeightPx = (): number => {
|
||||||
|
return Math.max(1, window.innerHeight - BOTTOM_UI_PX)
|
||||||
}
|
}
|
||||||
|
|
||||||
const scale = (): number => {
|
const scale = (): number => {
|
||||||
const height = window.innerHeight
|
const byWidth = window.innerWidth / viewportWidth
|
||||||
return height / viewportHeight
|
const byHeight = usableHeightPx() / MIN_VIEW_HEIGHT_M
|
||||||
|
// Width fit for sidelines; height cap so house/backline stay on-screen on wide displays.
|
||||||
|
return Math.min(byWidth, byHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const viewportHeight = (): number => {
|
||||||
|
return usableHeightPx() / scale()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vertical center of the usable ice area (not the full window). */
|
||||||
|
const viewCenterY = (): number => {
|
||||||
|
return usableHeightPx() / 2
|
||||||
|
}
|
||||||
|
|
||||||
|
const effectiveViewportCenter = () => ({
|
||||||
|
x: viewportCenter.x,
|
||||||
|
y: viewportCenter.y - viewportYOffset,
|
||||||
|
})
|
||||||
|
|
||||||
const worldToScreen = (x: number, y: number) => {
|
const worldToScreen = (x: number, y: number) => {
|
||||||
const s = scale()
|
const s = scale()
|
||||||
const cx = window.innerWidth / 2
|
const cx = window.innerWidth / 2
|
||||||
const cy = window.innerHeight / 2
|
const cy = viewCenterY()
|
||||||
|
const center = effectiveViewportCenter()
|
||||||
return {
|
return {
|
||||||
x: cx + (x - viewportCenter.x) * s,
|
x: cx + (x - center.x) * s,
|
||||||
y: cy + (y - viewportCenter.y) * s,
|
y: cy + (y - center.y) * s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenToWorld = (sx: number, sy: number) => {
|
const screenToWorld = (sx: number, sy: number) => {
|
||||||
const s = scale()
|
const s = scale()
|
||||||
const cx = window.innerWidth / 2
|
const cx = window.innerWidth / 2
|
||||||
const cy = window.innerHeight / 2
|
const cy = viewCenterY()
|
||||||
|
const center = effectiveViewportCenter()
|
||||||
return {
|
return {
|
||||||
x: (sx - cx) / s + viewportCenter.x,
|
x: (sx - cx) / s + center.x,
|
||||||
y: viewportCenter.y + (sy - cy) / s,
|
y: center.y + (sy - cy) / s,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const clampViewYOffset = (): number => {
|
||||||
|
// Positive offset pans the view upward (toward the hog line).
|
||||||
|
// When the usable viewport is tall enough, minOffset pins BACK_LINE_Y to the
|
||||||
|
// bottom of the usable area (just above the HUD), not under the controls.
|
||||||
|
const halfHeight = viewportHeight() / 2
|
||||||
|
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
|
||||||
|
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
|
||||||
|
return Math.max(minOffset, Math.min(maxOffset, viewportYOffset))
|
||||||
|
}
|
||||||
|
|
||||||
|
const setViewYOffset = (y: number) => {
|
||||||
|
const halfHeight = viewportHeight() / 2
|
||||||
|
const maxOffset = HOUSE_CENTER.y - halfHeight - HOG_LINE_Y
|
||||||
|
const minOffset = -(BACK_LINE_Y - (HOUSE_CENTER.y + halfHeight))
|
||||||
|
viewportYOffset = Math.max(minOffset, Math.min(maxOffset, y))
|
||||||
|
}
|
||||||
|
|
||||||
const drawLine = (
|
const drawLine = (
|
||||||
x1: number,
|
x1: number,
|
||||||
y1: number,
|
y1: number,
|
||||||
@ -102,7 +156,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const drawStone = (stone: StoneState | { x: number; y: number; rotation: number; team: Team }) => {
|
const drawStone = (stone: DrawableStone) => {
|
||||||
const c = worldToScreen(stone.x, stone.y)
|
const c = worldToScreen(stone.x, stone.y)
|
||||||
const r = STONE_RADIUS * scale()
|
const r = STONE_RADIUS * scale()
|
||||||
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
||||||
@ -129,7 +183,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
stones: StoneState[]
|
stones: StoneState[]
|
||||||
broom: { x: number; y: number } | null
|
broom: { x: number; y: number } | null
|
||||||
animating: boolean
|
animating: boolean
|
||||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
activeStonePos: DrawableStone[]
|
||||||
}) => {
|
}) => {
|
||||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||||
|
|
||||||
@ -149,12 +203,14 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)')
|
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, FOUR_FT_RADIUS, 'rgba(255,255,255,0.12)', 'rgba(255,255,255,0.35)')
|
||||||
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff')
|
drawCircle(HOUSE_CENTER.x, HOUSE_CENTER.y, BUTTON_RADIUS, '#e8e8e8', '#fff')
|
||||||
|
|
||||||
|
if (state.animating) {
|
||||||
|
for (const stone of state.activeStonePos) {
|
||||||
|
drawStone(stone)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
for (const stone of state.stones) {
|
for (const stone of state.stones) {
|
||||||
drawStone(stone)
|
drawStone(stone)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.activeStonePos) {
|
|
||||||
drawStone(state.activeStonePos)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.broom) {
|
if (state.broom) {
|
||||||
@ -170,5 +226,5 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
|||||||
setSize()
|
setSize()
|
||||||
window.addEventListener('resize', setSize)
|
window.addEventListener('resize', setSize)
|
||||||
|
|
||||||
return { canvas, ctx, setSize, worldToScreen, screenToWorld, draw }
|
return { canvas, ctx, setSize, worldToScreen, screenToWorld, setViewYOffset, clampViewYOffset, draw }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,30 +84,57 @@ html, body {
|
|||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#hud-top-group {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
#share {
|
#share {
|
||||||
position: absolute;
|
display: flex;
|
||||||
bottom: 12px;
|
justify-content: center;
|
||||||
left: 50%;
|
width: 100%;
|
||||||
transform: translateX(-50%);
|
}
|
||||||
text-align: center;
|
|
||||||
|
#share-row {
|
||||||
|
justify-content: center;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#share-row #share {
|
||||||
|
position: static;
|
||||||
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#share button {
|
#share button {
|
||||||
background: rgba(255, 255, 255, 0.15);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.3);
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
color: white;
|
color: white;
|
||||||
padding: 8px 14px;
|
padding: 6px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
font-size: 13px;
|
font-size: 12px;
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
#team {
|
#team {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
#team-select {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 4px 8px;
|
padding: 4px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.15);
|
||||||
|
color: white;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
||||||
|
pointer-events: auto;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#team-select option {
|
||||||
|
background: #0b1f3a;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
#velocity-control {
|
#velocity-control {
|
||||||
@ -205,67 +232,6 @@ html, body {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#team-picker {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
background: rgba(0, 0, 0, 0.75);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
z-index: 20;
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-picker-box {
|
|
||||||
background: #0b1f3a;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
text-align: center;
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-picker-box h2 {
|
|
||||||
margin: 0 0 16px 0;
|
|
||||||
font-size: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-picker-buttons {
|
|
||||||
display: flex;
|
|
||||||
gap: 16px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-btn {
|
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
border-radius: 16px;
|
|
||||||
border: 2px solid rgba(255, 255, 255, 0.4);
|
|
||||||
color: white;
|
|
||||||
font-weight: 700;
|
|
||||||
font-size: 16px;
|
|
||||||
cursor: pointer;
|
|
||||||
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-btn.team-red {
|
|
||||||
background: #cc3333;
|
|
||||||
}
|
|
||||||
.team-btn.team-red:hover {
|
|
||||||
background: #e04444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.team-btn.team-yellow {
|
|
||||||
background: #ccaa00;
|
|
||||||
}
|
|
||||||
.team-btn.team-yellow:hover {
|
|
||||||
background: #e0be00;
|
|
||||||
}
|
|
||||||
|
|
||||||
#throw-btn {
|
#throw-btn {
|
||||||
width: 80px;
|
width: 80px;
|
||||||
height: 80px;
|
height: 80px;
|
||||||
@ -283,3 +249,53 @@ html, body {
|
|||||||
background: #557766;
|
background: #557766;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: rgba(255, 255, 255, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
#hud {
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hud-row {
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#velocity-control {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.velocity-readout {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.curl-btn {
|
||||||
|
min-width: 44px;
|
||||||
|
width: 44px;
|
||||||
|
height: 36px;
|
||||||
|
font-size: 18px;
|
||||||
|
border-color: rgba(255, 255, 255, 0.6);
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
#friction-control {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friction-slider {
|
||||||
|
width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#friction-value {
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#throw-btn {
|
||||||
|
width: 56px;
|
||||||
|
height: 56px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user
don't specify stone color here, just team1 or team2, allow generalizability to choose colors. for now we will default to red and yellow
instead of a score. call it scoreboard. for each end, specify team with hammer, score for team1, score for team2. show this scoreboard on the frontend. this allows the hammer and turn_team variables to go away.