Compare commits

..

No commits in common. "0ef0dc3d78bf0c43fc09d7bb1e517395a03e4a6b" and "27e524a418a8b4d73bc599453ae9c9fd3a970818" have entirely different histories.

7 changed files with 284 additions and 340 deletions

View File

@ -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,31 +42,43 @@ 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 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
}
}
};
let player = Player { id, team, connected: true };
*self.slot_for(&team) = Some(player);
Some(team)
}
fn slot_for(&mut self, team: &Team) -> &mut Option<Player> {
match team {
Team::Red => &mut self.red,
Team::Yellow => &mut self.yellow,
}
}
fn first_open_team(&self) -> Option<Team> {
if self.red.is_none() {
Some(Team::Red)
} else if self.yellow.is_none() {
Some(Team::Yellow)
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
}
@ -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
}
}

View File

@ -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 err = serde_json::to_string(&ServerMessage::Error {
message: "Room is full".to_string(),
})
.unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
return;
let (mut sender, mut receiver) = socket.split();
// Reject room-full before subscribing so the error goes only to the joining socket.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let err = serde_json::to_string(&ServerMessage::Error {
message: "Room is full".to_string(),
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(err))).await;
return;
}
}
let player_id = Uuid::new_v4().to_string();
let team = register_player(&room, &player_id, preferred_team).await;
// Add the player to the room and tell only this socket its assigned team.
let team = {
let mut room = room_arc.lock().await;
let team = room.game.add_player(player_id.clone(), preferred_team)
.unwrap_or(Team::Red);
if room.game.can_start() {
room.game.start();
}
team
};
let joined = serde_json::to_string(&ServerMessage::Joined {
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
room: room_id.clone(),
team,
})
.unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined))).await;
}).unwrap();
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
let tx = { room.lock().await.tx.clone() };
// 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 = 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 {
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()
} else {
ServerMessage::Waiting {
message: "Waiting for other player".to_string(),
// Broadcast waiting/game state to everyone in the room.
{
let room = room_arc.lock().await;
if room.game.can_start() {
let state_msg = room.game.game_state_message();
let _ = room.tx.send(state_msg);
} else {
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
}
};
let _ = 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;
}
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 _ = tx.send(state_message);
if let Some(over) = game_over {
let _ = tx.send(over);
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(
}
}
}
})
}
});
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);
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);
}
}

View File

@ -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;

View File

@ -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,
)
}
}

View File

@ -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) {
updateControls()
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()

View File

@ -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

View File

@ -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)