Compare commits
No commits in common. "0ef0dc3d78bf0c43fc09d7bb1e517395a03e4a6b" and "27e524a418a8b4d73bc599453ae9c9fd3a970818" have entirely different histories.
0ef0dc3d78
...
27e524a418
@ -24,13 +24,7 @@ pub struct Game {
|
||||
stones_red: u8,
|
||||
stones_yellow: u8,
|
||||
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
||||
}
|
||||
|
||||
pub struct ThrowOutcome {
|
||||
pub trajectory: Vec<(f32, f32, f32)>,
|
||||
pub end_scored: Option<ServerMessage>,
|
||||
pub state_message: ServerMessage,
|
||||
pub game_over: Option<ServerMessage>,
|
||||
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
|
||||
}
|
||||
|
||||
impl Game {
|
||||
@ -48,27 +42,16 @@ impl Game {
|
||||
stones_red: STONES_PER_TEAM,
|
||||
stones_yellow: STONES_PER_TEAM,
|
||||
last_end_scored: None,
|
||||
room_tx: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
|
||||
let team = preferred
|
||||
.filter(|t| self.slot_for(t).is_none())
|
||||
.or_else(|| self.first_open_team())?;
|
||||
|
||||
let player = Player { id, team, connected: true };
|
||||
*self.slot_for(&team) = Some(player);
|
||||
Some(team)
|
||||
}
|
||||
|
||||
fn slot_for(&mut self, team: &Team) -> &mut Option<Player> {
|
||||
match team {
|
||||
Team::Red => &mut self.red,
|
||||
Team::Yellow => &mut self.yellow,
|
||||
}
|
||||
}
|
||||
|
||||
fn first_open_team(&self) -> Option<Team> {
|
||||
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() {
|
||||
@ -77,6 +60,29 @@ impl Game {
|
||||
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 {
|
||||
@ -94,6 +100,15 @@ impl Game {
|
||||
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()
|
||||
}
|
||||
@ -122,6 +137,12 @@ impl Game {
|
||||
Some(&p.id)
|
||||
}
|
||||
|
||||
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
|
||||
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
|
||||
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
|
||||
None
|
||||
}
|
||||
|
||||
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||
let current_id = self.current_player_id().ok_or("No current player")?;
|
||||
if current_id != player_id {
|
||||
@ -150,34 +171,6 @@ impl Game {
|
||||
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) {
|
||||
if self.phase != GamePhase::Simulating {
|
||||
return;
|
||||
@ -186,6 +179,10 @@ impl Game {
|
||||
self.score_end_internal(false);
|
||||
}
|
||||
|
||||
pub fn score_end(&mut self) {
|
||||
self.score_end_internal(true);
|
||||
}
|
||||
|
||||
fn score_end_internal(&mut self, force: bool) {
|
||||
let end_done = force || (self.stones_red == 0 && self.stones_yellow == 0);
|
||||
if !end_done {
|
||||
@ -232,10 +229,10 @@ impl Game {
|
||||
|
||||
self.last_end_scored = Some((self.end, points, scoring_team));
|
||||
self.phase = GamePhase::EndComplete;
|
||||
self.advance_end_or_finish();
|
||||
self.end_ends_or_continue(points, scoring_team);
|
||||
}
|
||||
|
||||
fn advance_end_or_finish(&mut self) {
|
||||
fn end_ends_or_continue(&mut self, _points: i32, _scoring_team: Option<Team>) {
|
||||
let tied = self.scores[0] == self.scores[1];
|
||||
let after_regulation = self.end >= ENDS;
|
||||
|
||||
@ -244,6 +241,10 @@ impl Game {
|
||||
return;
|
||||
}
|
||||
|
||||
if after_regulation && tied {
|
||||
// Extra end
|
||||
}
|
||||
|
||||
self.end += 1;
|
||||
self.stones_red = STONES_PER_TEAM;
|
||||
self.stones_yellow = STONES_PER_TEAM;
|
||||
@ -254,6 +255,10 @@ impl Game {
|
||||
self.phase = GamePhase::Playing;
|
||||
}
|
||||
|
||||
pub fn phase(&self) -> GamePhase {
|
||||
self.phase
|
||||
}
|
||||
|
||||
pub fn take_last_end_scored(&mut self) -> Option<ServerMessage> {
|
||||
let msg = self.last_end_scored.map(|(end, points, scoring_team)| {
|
||||
ServerMessage::EndScored { end, points, scoring_team }
|
||||
@ -295,18 +300,24 @@ impl Game {
|
||||
}
|
||||
}
|
||||
|
||||
pub type RoomId = String;
|
||||
|
||||
pub struct Room {
|
||||
pub id: RoomId,
|
||||
pub game: Game,
|
||||
pub tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
}
|
||||
|
||||
impl Room {
|
||||
pub fn new(_id: &str) -> Self {
|
||||
pub fn new(id: &str) -> Self {
|
||||
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||
Self {
|
||||
let mut room = Self {
|
||||
id: id.to_string(),
|
||||
game: Game::new(),
|
||||
tx,
|
||||
}
|
||||
};
|
||||
room.game.room_tx = Some(room.tx.clone());
|
||||
room
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,35 +13,30 @@ use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
|
||||
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::sync::Mutex as TokioMutex;
|
||||
use tracing::{info, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::game::{Room, ThrowOutcome};
|
||||
use crate::game::{GamePhase, Room};
|
||||
use crate::protocol::*;
|
||||
|
||||
type WsSender = futures_util::stream::SplitSink<WebSocket, Message>;
|
||||
type WsReceiver = futures_util::stream::SplitStream<WebSocket>;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
rooms: Arc<Mutex<HashMap<String, Arc<Mutex<Room>>>>>,
|
||||
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
|
||||
}
|
||||
|
||||
async fn get_or_create_room(&self, room_id: &str) -> Arc<Mutex<Room>> {
|
||||
let mut rooms = self.rooms.lock().await;
|
||||
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
|
||||
let mut rooms = self.rooms.lock().unwrap();
|
||||
if let Some(room) = rooms.get(room_id).cloned() {
|
||||
return room;
|
||||
}
|
||||
let room = Arc::new(Mutex::new(Room::new(room_id)));
|
||||
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
|
||||
rooms.insert(room_id.to_string(), room.clone());
|
||||
room
|
||||
}
|
||||
@ -86,7 +81,7 @@ async fn health() -> impl IntoResponse {
|
||||
|
||||
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let room_id = generate_room_code();
|
||||
state.get_or_create_room(&room_id).await;
|
||||
state.get_or_create_room(&room_id);
|
||||
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
|
||||
}
|
||||
|
||||
@ -98,78 +93,50 @@ async fn ws_handler(
|
||||
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>,
|
||||
) {
|
||||
let room = state.get_or_create_room(&room_id).await;
|
||||
let (mut sender, receiver) = socket.split();
|
||||
async fn handle_socket(socket: WebSocket, state: Arc<AppState>, room_id: String, preferred_team: Option<Team>) {
|
||||
let room_arc = state.get_or_create_room(&room_id);
|
||||
let player_id = Uuid::new_v4().to_string();
|
||||
let player_id_for_recv = player_id.clone();
|
||||
|
||||
if try_join_room(&room).await.is_err() {
|
||||
let (mut sender, mut receiver) = socket.split();
|
||||
|
||||
// Reject room-full before subscribing so the error goes only to the joining socket.
|
||||
{
|
||||
let room = room_arc.lock().await;
|
||||
if room.game.can_start() {
|
||||
let err = serde_json::to_string(&ServerMessage::Error {
|
||||
message: "Room is full".to_string(),
|
||||
})
|
||||
.unwrap();
|
||||
}).unwrap();
|
||||
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let player_id = Uuid::new_v4().to_string();
|
||||
let team = register_player(&room, &player_id, preferred_team).await;
|
||||
|
||||
let joined = serde_json::to_string(&ServerMessage::Joined {
|
||||
room: room_id.clone(),
|
||||
team,
|
||||
})
|
||||
.unwrap();
|
||||
let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await;
|
||||
|
||||
let tx = { room.lock().await.tx.clone() };
|
||||
|
||||
let send_task = spawn_forwarder(sender, tx.subscribe());
|
||||
broadcast_room_state(&room, &tx).await;
|
||||
|
||||
let recv_task = spawn_message_handler(room.clone(), player_id.clone(), tx, receiver);
|
||||
|
||||
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)
|
||||
// Add the player to the room and tell only this socket its assigned team.
|
||||
let team = {
|
||||
let mut room = room_arc.lock().await;
|
||||
let team = room.game.add_player(player_id.clone(), preferred_team)
|
||||
.unwrap_or(Team::Red);
|
||||
if room_guard.game.can_start() {
|
||||
room_guard.game.start();
|
||||
if room.game.can_start() {
|
||||
room.game.start();
|
||||
}
|
||||
team
|
||||
}
|
||||
};
|
||||
|
||||
fn spawn_forwarder(
|
||||
mut sender: WsSender,
|
||||
mut rx: tokio::sync::broadcast::Receiver<ServerMessage>,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
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 {
|
||||
match rx.recv().await {
|
||||
Ok(msg) => {
|
||||
@ -184,59 +151,49 @@ fn spawn_forwarder(
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
|
||||
async fn broadcast_room_state(
|
||||
room: &Arc<Mutex<Room>>,
|
||||
tx: &tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
) {
|
||||
let room_guard = room.lock().await;
|
||||
let msg = if room_guard.game.can_start() {
|
||||
room_guard.game.game_state_message()
|
||||
// Broadcast waiting/game state to everyone in the room.
|
||||
{
|
||||
let room = room_arc.lock().await;
|
||||
if room.game.can_start() {
|
||||
let state_msg = room.game.game_state_message();
|
||||
let _ = room.tx.send(state_msg);
|
||||
} else {
|
||||
ServerMessage::Waiting {
|
||||
message: "Waiting for other player".to_string(),
|
||||
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
|
||||
}
|
||||
};
|
||||
let _ = tx.send(msg);
|
||||
}
|
||||
|
||||
fn spawn_message_handler(
|
||||
room: Arc<Mutex<Room>>,
|
||||
player_id: String,
|
||||
tx: tokio::sync::broadcast::Sender<ServerMessage>,
|
||||
mut receiver: WsReceiver,
|
||||
) -> tokio::task::JoinHandle<()> {
|
||||
tokio::spawn(async move {
|
||||
let recv_room = room_arc.clone();
|
||||
let recv_id = player_id_for_recv;
|
||||
let recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(msg)) = receiver.next().await {
|
||||
let Message::Text(text) = msg else { continue; };
|
||||
let text_ref = text.as_str();
|
||||
let parsed: Result<ClientMessage, _> = serde_json::from_str(text_ref);
|
||||
match parsed {
|
||||
Ok(ClientMessage::Throw { broom_x, broom_y, weight, curl, friction }) => {
|
||||
let mut room_guard = room.lock().await;
|
||||
match room_guard
|
||||
.game
|
||||
.process_throw(&player_id, broom_x, broom_y, weight, curl, friction)
|
||||
{
|
||||
Ok(ThrowOutcome {
|
||||
trajectory,
|
||||
end_scored,
|
||||
state_message,
|
||||
game_over,
|
||||
}) => {
|
||||
let _ = tx.send(ServerMessage::Trajectory { path: trajectory });
|
||||
if let Some(scored) = end_scored {
|
||||
let _ = tx.send(scored);
|
||||
let room = recv_room.clone();
|
||||
let mut room = room.lock().await;
|
||||
if room.game.current_team_for_player(&recv_id) != Some(team) {
|
||||
let _ = room.tx.send(ServerMessage::Error { message: "Not your turn".to_string() });
|
||||
continue;
|
||||
}
|
||||
let _ = tx.send(state_message);
|
||||
if let Some(over) = game_over {
|
||||
let _ = tx.send(over);
|
||||
match room.game.handle_throw(&recv_id, broom_x, broom_y, weight, curl, friction) {
|
||||
Ok(path) => {
|
||||
room.tx.send(ServerMessage::Trajectory { path }).ok();
|
||||
room.game.finish_simulation();
|
||||
if let Some(scored) = room.game.take_last_end_scored() {
|
||||
room.tx.send(scored).ok();
|
||||
}
|
||||
let after = room.game.game_state_message();
|
||||
room.tx.send(after).ok();
|
||||
if room.game.phase() == GamePhase::GameComplete {
|
||||
room.tx.send(room.game.game_over_message()).ok();
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = tx.send(ServerMessage::Error { message: e });
|
||||
room.tx.send(ServerMessage::Error { message: e }).ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -245,10 +202,16 @@ fn spawn_message_handler(
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = send_task => {}
|
||||
_ = recv_task => {}
|
||||
}
|
||||
|
||||
async fn remove_player(room: &Arc<Mutex<Room>>, player_id: &str) {
|
||||
let mut room_guard = room.lock().await;
|
||||
room_guard.game.remove_player(player_id);
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,10 @@ pub const SHEET_WIDTH: f32 = 5.0;
|
||||
pub const SHEET_LENGTH: f32 = 45.0;
|
||||
pub const HOUSE_CENTER: (f32, f32) = (0.0, 38.5);
|
||||
pub const HOUSE_RADIUS: f32 = 1.83; // 12 ft
|
||||
pub const BUTTON_RADIUS: f32 = 0.1524; // 0.5 ft
|
||||
pub const FOUR_FT_RADIUS: f32 = 0.6096;
|
||||
pub const EIGHT_FT_RADIUS: f32 = 1.2192;
|
||||
pub const TWELVE_FT_RADIUS: f32 = 1.8288;
|
||||
pub const HOG_LINE_Y: f32 = 21.0;
|
||||
pub const BACK_LINE_Y: f32 = 42.0;
|
||||
pub const HACK_Y: f32 = 2.0;
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -1,8 +1,19 @@
|
||||
import { connect, sendThrow, type NetCallbacks } from './net'
|
||||
import { createRenderer } from './renderer'
|
||||
import { createHud, createVelocitySelector, createCurlSelector, createFrictionSlider } from './hud'
|
||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Team } from './protocol'
|
||||
import { GameModel } from './game-model'
|
||||
import { HOUSE_CENTER, HOUSE_RADIUS, type Phase, type StoneState, type Team } from './protocol'
|
||||
import { trimPathToStartAtHogLine } from './game-helpers'
|
||||
|
||||
interface GameState {
|
||||
end: number
|
||||
scores: number[]
|
||||
hammer: Team
|
||||
turnTeam: Team
|
||||
myTeam: Team | null
|
||||
phase: Phase
|
||||
stones: StoneState[]
|
||||
animating: boolean
|
||||
}
|
||||
|
||||
export function startGame(): void {
|
||||
const app = document.querySelector<HTMLDivElement>('#app')!
|
||||
@ -44,8 +55,6 @@ export function startGame(): void {
|
||||
`
|
||||
app.appendChild(picker)
|
||||
|
||||
const model = new GameModel()
|
||||
|
||||
picker.querySelectorAll<HTMLButtonElement>('.team-btn').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const team = btn.dataset.team as Team
|
||||
@ -54,31 +63,77 @@ 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 myTurn = model.isMyTurn
|
||||
velocity.setEnabled(myTurn)
|
||||
curls.setEnabled(myTurn)
|
||||
friction.setEnabled(myTurn)
|
||||
throwBtn.disabled = !myTurn
|
||||
hud.update(model.state)
|
||||
const myTurn = Boolean(
|
||||
gameState.myTeam && gameState.turnTeam === gameState.myTeam && gameState.phase === 'playing',
|
||||
)
|
||||
velocity.setEnabled(myTurn && !gameState.animating)
|
||||
curls.setEnabled(myTurn && !gameState.animating)
|
||||
friction.setEnabled(myTurn && !gameState.animating)
|
||||
throwBtn.disabled = !myTurn || gameState.animating
|
||||
if (gameState.phase === 'simulating' || gameState.animating) {
|
||||
velocity.setEnabled(false)
|
||||
curls.setEnabled(false)
|
||||
friction.setEnabled(false)
|
||||
}
|
||||
hud.update(gameState)
|
||||
}
|
||||
|
||||
const render = () => {
|
||||
const wasAnimating = model.state.animating
|
||||
const activeStonePos = model.tick(performance.now())
|
||||
if (wasAnimating && !model.state.animating) {
|
||||
let activeStonePos: { x: number; y: number; rotation: number; team: Team } | null = null
|
||||
if (gameState.animating && activePath.length > 1) {
|
||||
const elapsed = (performance.now() - animationStartTime) / 1000
|
||||
const total = activePath[activePath.length - 1][2]
|
||||
if (elapsed >= total) {
|
||||
gameState.animating = false
|
||||
if (pendingStones.length > 0) {
|
||||
gameState.stones = pendingStones
|
||||
pendingStones = []
|
||||
}
|
||||
updateControls()
|
||||
} else {
|
||||
let i = 0
|
||||
while (i + 1 < activePath.length && activePath[i + 1][2] < elapsed) i++
|
||||
const p0 = activePath[i]
|
||||
const p1 = activePath[i + 1] ?? p0
|
||||
const t0 = activePath[Math.max(i - 1, 0)]
|
||||
const t2 = activePath[Math.min(i + 2, activePath.length - 1)]
|
||||
const dt = p1[2] - p0[2]
|
||||
const t = dt > 0 ? (elapsed - p0[2]) / dt : 0
|
||||
const x = p0[0] + (p1[0] - p0[0]) * t
|
||||
const y = p0[1] + (p1[1] - p0[1]) * t
|
||||
const dx = t2[0] - t0[0]
|
||||
const dy = t2[1] - t0[1]
|
||||
const rotation = Math.atan2(dy, dx) * 2
|
||||
activeStonePos = { x, y, rotation, team: animationTeam }
|
||||
}
|
||||
}
|
||||
|
||||
renderer.draw({
|
||||
stones: model.state.stones,
|
||||
stones: gameState.stones,
|
||||
broom:
|
||||
model.state.phase === 'playing' &&
|
||||
!model.state.animating &&
|
||||
model.state.myTeam === model.state.turnTeam
|
||||
? model.broom
|
||||
gameState.phase === 'playing' && !gameState.animating && gameState.myTeam === gameState.turnTeam
|
||||
? broom
|
||||
: null,
|
||||
animating: model.state.animating,
|
||||
animating: gameState.animating,
|
||||
activeStonePos,
|
||||
})
|
||||
requestAnimationFrame(render)
|
||||
@ -86,19 +141,42 @@ export function startGame(): void {
|
||||
|
||||
const callbacks: NetCallbacks = {
|
||||
onJoined: (_roomId, team) => {
|
||||
model.setMyTeam(team)
|
||||
gameState.myTeam = team
|
||||
updateControls()
|
||||
},
|
||||
onWaiting: () => {
|
||||
model.setWaiting()
|
||||
gameState.phase = 'waiting'
|
||||
updateControls()
|
||||
},
|
||||
onGameState: (msg) => {
|
||||
model.updateGameState(msg)
|
||||
const reset = msg.phase === 'waiting' || msg.end !== gameState.end
|
||||
if (reset) {
|
||||
gameState.stones = []
|
||||
pendingStones = []
|
||||
}
|
||||
|
||||
if (gameState.animating) {
|
||||
pendingStones = msg.stones
|
||||
} else {
|
||||
gameState.stones = msg.stones
|
||||
}
|
||||
|
||||
gameState = {
|
||||
...gameState,
|
||||
end: msg.end,
|
||||
scores: msg.scores,
|
||||
hammer: msg.hammer,
|
||||
turnTeam: msg.turn_team,
|
||||
phase: msg.phase,
|
||||
}
|
||||
updateControls()
|
||||
},
|
||||
onTrajectory: (path) => {
|
||||
model.startTrajectory(path, model.state.turnTeam)
|
||||
activePath = trimPathToStartAtHogLine(path)
|
||||
gameState.animating = activePath.length > 1
|
||||
animationStartTime = performance.now()
|
||||
animationTeam = gameState.turnTeam
|
||||
pendingStones = []
|
||||
updateControls()
|
||||
},
|
||||
onEndScored: (end, points, scoringTeam) => {
|
||||
@ -143,21 +221,30 @@ export function startGame(): void {
|
||||
}
|
||||
|
||||
const handleStart = (e: Event) => {
|
||||
if (!model.isMyTurn) return
|
||||
model.isDragging = true
|
||||
if (!isMyDragTurn()) return
|
||||
isDragging = true
|
||||
const pos = getPos(e as TouchEvent)
|
||||
model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
}
|
||||
|
||||
const handleMove = (e: Event) => {
|
||||
if (!model.isDragging || !model.isMyTurn) return
|
||||
if (!isDragging || !isMyDragTurn()) return
|
||||
e.preventDefault()
|
||||
const pos = getPos(e as TouchEvent)
|
||||
model.broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
broom = constrainBroom(renderer.screenToWorld(pos.x, pos.y))
|
||||
}
|
||||
|
||||
const handleEnd = () => {
|
||||
model.isDragging = false
|
||||
isDragging = false
|
||||
}
|
||||
|
||||
const isMyDragTurn = () => {
|
||||
return (
|
||||
gameState.myTeam !== null &&
|
||||
gameState.turnTeam === gameState.myTeam &&
|
||||
gameState.phase === 'playing' &&
|
||||
!gameState.animating
|
||||
)
|
||||
}
|
||||
|
||||
canvas.addEventListener('touchstart', handleStart, { passive: false })
|
||||
@ -169,8 +256,8 @@ export function startGame(): void {
|
||||
canvas.addEventListener('mouseleave', handleEnd)
|
||||
|
||||
throwBtn.addEventListener('click', () => {
|
||||
if (!model.isMyTurn) return
|
||||
sendThrow(model.broom.x, model.broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
||||
if (gameState.myTeam !== gameState.turnTeam || gameState.animating || gameState.phase !== 'playing') return
|
||||
sendThrow(broom.x, broom.y, velocity.getWeight(), curls.getSelected(), friction.getFriction())
|
||||
})
|
||||
|
||||
updateControls()
|
||||
|
||||
@ -26,13 +26,6 @@ export interface StoneState {
|
||||
active: boolean
|
||||
}
|
||||
|
||||
export interface DrawableStone {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
team: Team
|
||||
}
|
||||
|
||||
export interface ClientThrowMessage {
|
||||
type: 'throw'
|
||||
broom_x: number
|
||||
|
||||
@ -9,8 +9,8 @@ import {
|
||||
STONE_RADIUS,
|
||||
TWELVE_FT_RADIUS,
|
||||
HOUSE_CENTER,
|
||||
type DrawableStone,
|
||||
type StoneState,
|
||||
type Team,
|
||||
} from './protocol'
|
||||
|
||||
export interface Renderer {
|
||||
@ -23,7 +23,7 @@ export interface Renderer {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: DrawableStone | null
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
}) => void
|
||||
}
|
||||
|
||||
@ -102,7 +102,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
}
|
||||
}
|
||||
|
||||
const drawStone = (stone: DrawableStone) => {
|
||||
const drawStone = (stone: StoneState | { x: number; y: number; rotation: number; team: Team }) => {
|
||||
const c = worldToScreen(stone.x, stone.y)
|
||||
const r = STONE_RADIUS * scale()
|
||||
const color = stone.team === 'red' ? '#d93025' : '#f9ab00'
|
||||
@ -129,7 +129,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
|
||||
stones: StoneState[]
|
||||
broom: { x: number; y: number } | null
|
||||
animating: boolean
|
||||
activeStonePos: DrawableStone | null
|
||||
activeStonePos: { x: number; y: number; rotation: number; team: Team } | null
|
||||
}) => {
|
||||
ctx.clearRect(0, 0, window.innerWidth, window.innerHeight)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user