Compare commits

...

5 Commits

Author SHA1 Message Date
eros
0ef0dc3d78 refactor(backend): remove remaining dead code and unused constants
Drop unused Game methods, Room.id field, and backend-only sheet ring constants that duplicated frontend definitions.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:58:35 -07:00
eros
0ca0f0e3ff refactor(frontend): centralize game state in GameModel
Extract mutable state from game.ts into a dedicated GameModel class to clarify data flow and state transitions.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:55:45 -07:00
eros
ef5349dfe3 refactor(frontend): introduce DrawableStone interface and use in renderer
Replace the ad-hoc StoneState | {...} union in drawStone/draw with a named DrawableStone type.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:51:37 -07:00
eros
23a0cafe18 refactor(backend): split handle_socket and make rooms map async-safe
Replace std::sync::Mutex with tokio::sync::Mutex; extract try_join_room, register_player, spawn_forwarder, broadcast_room_state, spawn_message_handler, remove_player.

Use Game::process_throw in the message handler.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:49:56 -07:00
eros
1f673b4eba refactor(backend): encapsulate throw lifecycle and clean dead code in game.rs
Add Game::process_throw to centralize throw→simulation→scoring→state flow.

Remove dead room_tx field, simplify add_player, and rename end_ends_or_continue.

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-24 10:46:12 -07:00
7 changed files with 341 additions and 285 deletions

View File

@ -24,7 +24,13 @@ 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<(f32, f32, f32)>,
pub end_scored: Option<ServerMessage>,
pub state_message: ServerMessage,
pub game_over: Option<ServerMessage>,
} }
impl Game { impl Game {
@ -42,43 +48,31 @@ 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,
} }
} }
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> { pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
let team = match preferred { let team = preferred
Some(Team::Red) if self.red.is_none() => Some(Team::Red), .filter(|t| self.slot_for(t).is_none())
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow), .or_else(|| self.first_open_team())?;
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, connected: true };
let player = Player { id, team: t, connected: true }; *self.slot_for(&team) = Some(player);
match t { Some(team)
Team::Red => self.red = Some(player), }
Team::Yellow => self.yellow = Some(player),
} fn slot_for(&mut self, team: &Team) -> &mut Option<Player> {
Some(t) match team {
Team::Red => &mut self.red,
Team::Yellow => &mut self.yellow,
}
}
fn first_open_team(&self) -> Option<Team> {
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
} else { } else {
None None
} }
@ -100,15 +94,6 @@ impl Game {
None 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 { pub fn can_start(&self) -> bool {
self.red.is_some() && self.yellow.is_some() self.red.is_some() && self.yellow.is_some()
} }
@ -137,12 +122,6 @@ impl Game {
Some(&p.id) Some(&p.id)
} }
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
None
}
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> { 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")?; let current_id = self.current_player_id().ok_or("No current player")?;
if current_id != player_id { if current_id != player_id {
@ -171,6 +150,34 @@ impl Game {
Ok(path) Ok(path)
} }
pub fn process_throw(
&mut self,
player_id: &str,
broom_x: f32,
broom_y: f32,
weight: u8,
curl: i8,
friction: f32,
) -> Result<ThrowOutcome, String> {
let trajectory = self.handle_throw(player_id, broom_x, broom_y, weight, curl, friction)?;
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) {
if self.phase != GamePhase::Simulating { if self.phase != GamePhase::Simulating {
return; return;
@ -179,10 +186,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 {
@ -229,10 +232,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 +244,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 +254,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 +295,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
} }
} }

View File

@ -13,30 +13,35 @@ use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
use futures_util::{sink::SinkExt, stream::StreamExt}; use futures_util::{sink::SinkExt, stream::StreamExt};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::{Arc, Mutex}; use std::sync::Arc;
use tokio::sync::Mutex as TokioMutex; use tokio::sync::Mutex;
use tracing::{info, warn}; use tracing::{info, warn};
use uuid::Uuid; use uuid::Uuid;
use crate::game::{GamePhase, Room}; use crate::game::{Room, ThrowOutcome};
use crate::protocol::*; use crate::protocol::*;
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>, rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
} }
impl AppState { impl AppState {
fn new() -> Self { fn new() -> Self {
Self { rooms: Arc::new(Mutex::new(HashMap::new())) } Self {
rooms: Arc::new(Mutex::new(HashMap::new())),
}
} }
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> { async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
let mut rooms = self.rooms.lock().unwrap(); let mut rooms = self.rooms.lock().await;
if let Some(room) = rooms.get(room_id).cloned() { if let Some(room) = rooms.get(room_id).cloned() {
return room; return room;
} }
let room = Arc::new(TokioMutex::new(Room::new(room_id))); let room = Arc::new(Mutex::new(Room::new(room_id)));
rooms.insert(room_id.to_string(), room.clone()); rooms.insert(room_id.to_string(), room.clone());
room room
} }
@ -81,7 +86,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 }))
} }
@ -93,50 +98,78 @@ async fn ws_handler(
ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team)) ws.on_upgrade(move |socket| handle_socket(socket, state, query.room, query.team))
} }
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,
preferred_team: Option<Team>,
) {
let room = state.get_or_create_room(&room_id).await;
let (mut sender, receiver) = socket.split();
let (mut sender, mut receiver) = socket.split(); if try_join_room(&room).await.is_err() {
let err = serde_json::to_string(&ServerMessage::Error {
// Reject room-full before subscribing so the error goes only to the joining socket. message: "Room is full".to_string(),
{ })
let room = room_arc.lock().await; .unwrap();
if room.game.can_start() { let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
let err = serde_json::to_string(&ServerMessage::Error { return;
message: "Room is full".to_string(),
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
return;
}
} }
// Add the player to the room and tell only this socket its assigned team. let player_id = Uuid::new_v4().to_string();
let team = { let team = register_player(&room, &player_id, preferred_team).await;
let mut room = room_arc.lock().await;
let team = room.game.add_player(player_id.clone(), preferred_team)
.unwrap_or(Team::Red);
if room.game.can_start() {
room.game.start();
}
team
};
let joined_msg = serde_json::to_string(&ServerMessage::Joined { let joined = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(), room: room_id.clone(),
team, team,
}).unwrap(); })
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await; .unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await;
// Subscribe to broadcast and spawn the forwarding task. let tx = { room.lock().await.tx.clone() };
let tx = {
let room = room_arc.lock().await;
room.tx.clone()
};
let mut rx = tx.subscribe();
let send_task = tokio::spawn(async move { let send_task = spawn_forwarder(sender, tx.subscribe());
broadcast_room_state(&room, &tx).await;
let recv_task = spawn_message_handler(room.clone(), player_id.clone(), tx, receiver);
tokio::select! {
_ = send_task => {}
_ = recv_task => {}
}
remove_player(&room, &player_id).await;
}
async fn try_join_room(room: &Arc<Mutex<Room>>) -> Result<(), ()> {
let room_guard = room.lock().await;
if room_guard.game.can_start() {
return Err(());
}
Ok(())
}
async fn register_player(
room: &Arc<Mutex<Room>>,
player_id: &str,
preferred_team: Option<Team>,
) -> Team {
let mut room_guard = room.lock().await;
let team = room_guard
.game
.add_player(player_id.to_string(), preferred_team)
.unwrap_or(Team::Red);
if room_guard.game.can_start() {
room_guard.game.start();
}
team
}
fn spawn_forwarder(
mut sender: WsSender,
mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
loop { loop {
match rx.recv().await { match rx.recv().await {
Ok(msg) => { Ok(msg) => {
@ -151,49 +184,59 @@ 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 msg = if room_guard.game.can_start() {
} else { room_guard.game.game_state_message()
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() }); } else {
ServerMessage::Waiting {
message: "Waiting for other player".to_string(),
} }
} };
let _ = tx.send(msg);
}
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 { player_id: String,
tx: tokio::sync::broadcast::Sender<ServerMessage>,
mut receiver: WsReceiver,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
while let Some(Ok(msg)) = receiver.next().await { 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 { 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(&player_id, broom_x, broom_y, weight, curl, friction)
continue; {
} Ok(ThrowOutcome {
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) { trajectory,
Ok(path) => { end_scored,
room.tx.send(ServerMessage::Trajectory { path }).ok(); state_message,
room.game.finish_simulation(); game_over,
if let Some(scored) = room.game.take_last_end_scored() { }) => {
room.tx.send(scored).ok(); let _ = tx.send(ServerMessage::Trajectory { path: trajectory });
if let Some(scored) = end_scored {
let _ = tx.send(scored);
} }
let after = room.game.game_state_message(); let _ = tx.send(state_message);
room.tx.send(after).ok(); if let Some(over) = game_over {
if room.game.phase() == GamePhase::GameComplete { let _ = tx.send(over);
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 +245,10 @@ async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String,
} }
} }
} }
}); })
}
tokio::select! {
_ = send_task => {} async fn remove_player(room: &Arc<Mutex<Room>>, player_id: &str) {
_ = recv_task => {} let mut room_guard = room.lock().await;
} room_guard.game.remove_player(player_id);
// On disconnect, free the team slot so refreshes and new tabs can rejoin.
{
let mut room = room_arc.lock().await;
let _ = room.game.remove_player(&player_id);
}
} }

View File

@ -13,10 +13,6 @@ 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 = 1.83; // 12 ft
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
pub const FOUR_FT_RADIUS: f32 = 0.6096;
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
pub const 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;

114
frontend/src/game-model.ts Normal file
View File

@ -0,0 +1,114 @@
import { HOUSE_CENTER, type DrawableStone, type Phase, type ServerGameStateMessage, 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
private activePath: [number, number, number][] = []
private animationStartTime = 0
private animationTeam: Team = 'red'
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(path: [number, number, number][], team: Team): void {
this.activePath = trimPathToStartAtHogLine(path)
this.state.animating = this.activePath.length > 1
this.animationStartTime = performance.now()
this.animationTeam = team
this.pendingStones = []
}
tick(now: number): DrawableStone | null {
if (!this.state.animating || this.activePath.length <= 1) {
return null
}
const elapsed = (now - this.animationStartTime) / 1000
const total = this.activePath[this.activePath.length - 1][2]
if (elapsed >= total) {
this.state.animating = false
if (this.pendingStones.length > 0) {
this.state.stones = this.pendingStones
this.pendingStones = []
}
return null
}
let i = 0
while (i + 1 < this.activePath.length && this.activePath[i + 1][2] < elapsed) i++
const p0 = this.activePath[i]
const p1 = this.activePath[i + 1] ?? p0
const t0 = this.activePath[Math.max(i - 1, 0)]
const t2 = this.activePath[Math.min(i + 2, this.activePath.length - 1)]
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, team: this.animationTeam }
}
get isMyTurn(): boolean {
return Boolean(
this.state.myTeam &&
this.state.turnTeam === this.state.myTeam &&
this.state.phase === 'playing' &&
!this.state.animating,
)
}
}

View File

@ -1,19 +1,8 @@
import { connect, sendThrow, type NetCallbacks } from './net' import { connect, sendThrow, type NetCallbacks } from './net'
import { createRenderer } from './renderer' import { createRenderer } from './renderer'
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud' import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol' import { HOUSE_CENTER, HOUSE_RADIUS, 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')!
@ -55,6 +44,8 @@ export function startGame(): void {
` `
app.appendChild(picker) app.appendChild(picker)
const model = new GameModel()
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => { picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
btn.addEventListener('click', () => { btn.addEventListener('click', () => {
const team = btn.dataset.team as Team const team = btn.dataset.team as Team
@ -63,77 +54,31 @@ export function startGame(): void {
}) })
}) })
let gameState: GameState = {
end: 1,
scores: [0, 0],
hammer: 'red',
turnTeam: 'red',
myTeam: null,
phase: 'waiting',
stones: [],
animating: false,
}
let pendingStones: StoneState[] = []
let broom: { x: number; y: number } = { x: 0, y: HOUSE_CENTER.y }
let isDragging = false
let activePath: [number, number, number][] = []
let animationStartTime = 0
let animationTeam: Team = 'red'
const updateControls = () => { const updateControls = () => {
const myTurn = Boolean( const myTurn = model.isMyTurn
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing', velocity.setEnabled(myTurn)
) curls.setEnabled(myTurn)
velocity.setEnabled(myTurn && !gameState.animating) friction.setEnabled(myTurn)
curls.setEnabled(myTurn && !gameState.animating) throwBtn.disabled = !myTurn
friction.setEnabled(myTurn && !gameState.animating) hud.update(model.state)
throwBtn.disabled = !myTurn || gameState.animating
if (gameState.phase === 'simulating' || gameState.animating) {
velocity.setEnabled(false)
curls.setEnabled(false)
friction.setEnabled(false)
}
hud.update(gameState)
} }
const render = () => { const render = () => {
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null const wasAnimating = model.state.animating
if (gameState.animating && activePath.length > 1) { const activeStonePos = model.tick(performance.now())
const elapsed = (performance.now() - animationStartTime) / 1000 if (wasAnimating && !model.state.animating) {
const total = activePath[activePath.length - 1][2] updateControls()
if (elapsed >= total) {
gameState.animating = false
if (pendingStones.length > 0) {
gameState.stones = pendingStones
pendingStones = []
}
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)
@ -141,42 +86,19 @@ export function startGame(): void {
const callbacks: NetCallbacks = { const callbacks: NetCallbacks = {
onJoined: (_roomId, team) => { onJoined: (_roomId, team) => {
gameState.myTeam = team model.setMyTeam(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: (path) => {
activePath = trimPathToStartAtHogLine(path) model.startTrajectory(path, model.state.turnTeam)
gameState.animating = activePath.length > 1
animationStartTime = performance.now()
animationTeam = gameState.turnTeam
pendingStones = []
updateControls() updateControls()
}, },
onEndScored: (end, points, scoringTeam) => { onEndScored: (end, points, scoringTeam) => {
@ -221,30 +143,21 @@ export function startGame(): void {
} }
const handleStart = (e: Event) => { const handleStart = (e: Event) => {
if (!isMyDragTurn()) return if (!model.isMyTurn) return
isDragging = true model.isDragging = true
const pos = getPos(e as TouchEvent) const pos = getPos(e as TouchEvent)
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y)) model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
} }
const handleMove = (e: Event) => { const handleMove = (e: Event) => {
if (!isDragging || !isMyDragTurn()) return if (!model.isDragging || !model.isMyTurn) return
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 = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
} }
const handleEnd = () => { const handleEnd = () => {
isDragging = false model.isDragging = 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,8 +169,8 @@ 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(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
}) })
updateControls() updateControls()

View File

@ -26,6 +26,13 @@ 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'
broom_x: number broom_x: number

View File

@ -9,8 +9,8 @@ import {
STONE_RADIUS, STONE_RADIUS,
TWELVE_FT_RADIUS, TWELVE_FT_RADIUS,
HOUSE_CENTER, HOUSE_CENTER,
type DrawableStone,
type StoneState, type StoneState,
type Team,
} from './protocol' } from './protocol'
export interface Renderer { export interface Renderer {
@ -23,7 +23,7 @@ export interface 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 | null
}) => void }) => void
} }
@ -102,7 +102,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 +129,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 | null
}) => { }) => {
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight) ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)