feat: add Rust Axum backend with WebSocket game server
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
parent
0df556ff46
commit
5e0c188d9b
1
backend/.gitignore
vendored
Normal file
1
backend/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
1452
backend/Cargo.lock
generated
Normal file
1452
backend/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
backend/Cargo.toml
Normal file
20
backend/Cargo.toml
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
[package]
|
||||||
|
name = "curltastic-backend"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rapier2d = "0.33"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
futures-util = "0.3"
|
||||||
|
tokio-util = "0.7"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = "0.3"
|
||||||
|
rand = "0.8"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
tokio-test = "0.4"
|
||||||
354
backend/src/game.rs
Normal file
354
backend/src/game.rs
Normal file
@ -0,0 +1,354 @@
|
|||||||
|
use crate::protocol::*;
|
||||||
|
use crate::physics::PhysicsWorld;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
|
pub enum GamePhase {
|
||||||
|
Waiting,
|
||||||
|
Playing,
|
||||||
|
Simulating,
|
||||||
|
Scoring,
|
||||||
|
EndComplete,
|
||||||
|
GameComplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Game {
|
||||||
|
red: Option<Player>,
|
||||||
|
yellow: Option<Player>,
|
||||||
|
phase: GamePhase,
|
||||||
|
end: u8,
|
||||||
|
scores: [i32; 2],
|
||||||
|
hammer: Team,
|
||||||
|
turn_team: Team,
|
||||||
|
physics: PhysicsWorld,
|
||||||
|
active_stones: Vec<StoneState>,
|
||||||
|
stones_red: u8,
|
||||||
|
stones_yellow: u8,
|
||||||
|
last_end_scored: Option<(u8, i32, Option<Team>)>,
|
||||||
|
pub room_tx: Option<tokio::sync::broadcast::Sender<ServerMessage>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Game {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
red: None,
|
||||||
|
yellow: None,
|
||||||
|
phase: GamePhase::Waiting,
|
||||||
|
end: 1,
|
||||||
|
scores: [0, 0],
|
||||||
|
hammer: Team::Red,
|
||||||
|
turn_team: Team::Red,
|
||||||
|
physics: PhysicsWorld::new(),
|
||||||
|
active_stones: Vec::new(),
|
||||||
|
stones_red: STONES_PER_TEAM,
|
||||||
|
stones_yellow: STONES_PER_TEAM,
|
||||||
|
last_end_scored: None,
|
||||||
|
room_tx: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_player(&mut self, id: String, preferred: Option<Team>) -> Option<Team> {
|
||||||
|
let team = match preferred {
|
||||||
|
Some(Team::Red) if self.red.is_none() => Some(Team::Red),
|
||||||
|
Some(Team::Yellow) if self.yellow.is_none() => Some(Team::Yellow),
|
||||||
|
Some(_) => {
|
||||||
|
// Preferred is taken; fall back to the other free team.
|
||||||
|
if self.red.is_none() {
|
||||||
|
Some(Team::Red)
|
||||||
|
} else if self.yellow.is_none() {
|
||||||
|
Some(Team::Yellow)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// Legacy first-free logic.
|
||||||
|
if self.red.is_none() {
|
||||||
|
Some(Team::Red)
|
||||||
|
} else if self.yellow.is_none() {
|
||||||
|
Some(Team::Yellow)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(t) = team {
|
||||||
|
let player = Player { id, team: t, connected: true };
|
||||||
|
match t {
|
||||||
|
Team::Red => self.red = Some(player),
|
||||||
|
Team::Yellow => self.yellow = Some(player),
|
||||||
|
}
|
||||||
|
Some(t)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_player(&mut self, id: &str) -> Option<Team> {
|
||||||
|
if let Some(ref p) = self.red {
|
||||||
|
if p.id == id {
|
||||||
|
self.red = None;
|
||||||
|
return Some(Team::Red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(ref p) = self.yellow {
|
||||||
|
if p.id == id {
|
||||||
|
self.yellow = None;
|
||||||
|
return Some(Team::Yellow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_player_connected(&mut self, id: &str, connected: bool) {
|
||||||
|
if let Some(p) = self.red.as_mut().filter(|p| p.id == id) {
|
||||||
|
p.connected = connected;
|
||||||
|
}
|
||||||
|
if let Some(p) = self.yellow.as_mut().filter(|p| p.id == id) {
|
||||||
|
p.connected = connected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn can_start(&self) -> bool {
|
||||||
|
self.red.is_some() && self.yellow.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn start(&mut self) {
|
||||||
|
if !self.can_start() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.hammer = if rand::random() { Team::Red } else { Team::Yellow };
|
||||||
|
self.turn_team = self.hammer.other();
|
||||||
|
self.phase = GamePhase::Playing;
|
||||||
|
self.end = 1;
|
||||||
|
self.stones_red = STONES_PER_TEAM;
|
||||||
|
self.stones_yellow = STONES_PER_TEAM;
|
||||||
|
self.scores = [0, 0];
|
||||||
|
self.physics.reset();
|
||||||
|
self.physics.reset_stone_ids();
|
||||||
|
self.active_stones.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_player_id(&self) -> Option<&str> {
|
||||||
|
let p = match self.turn_team {
|
||||||
|
Team::Red => self.red.as_ref()?,
|
||||||
|
Team::Yellow => self.yellow.as_ref()?,
|
||||||
|
};
|
||||||
|
Some(&p.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_team_for_player(&self, player_id: &str) -> Option<Team> {
|
||||||
|
if let Some(p) = self.red.as_ref() { if p.id == player_id { return Some(Team::Red); } }
|
||||||
|
if let Some(p) = self.yellow.as_ref() { if p.id == player_id { return Some(Team::Yellow); } }
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn handle_throw(&mut self, player_id: &str, broom_x: f32, broom_y: f32, weight: u8, curl: i8, friction: f32) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||||
|
let current_id = self.current_player_id().ok_or("No current player")?;
|
||||||
|
if current_id != player_id {
|
||||||
|
return Err("Not your turn".to_string());
|
||||||
|
}
|
||||||
|
if self.phase != GamePhase::Playing {
|
||||||
|
return Err("Cannot throw now".to_string());
|
||||||
|
}
|
||||||
|
let dx = broom_x - HOUSE_CENTER.0;
|
||||||
|
let dy = broom_y - HOUSE_CENTER.1;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
if dist > HOUSE_RADIUS {
|
||||||
|
return Err("Broom must be in the house".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
self.active_stones.clear();
|
||||||
|
let path = self.physics.throw(self.turn_team, broom_x, broom_y, weight, curl, friction)?;
|
||||||
|
self.active_stones = self.physics.current_stones();
|
||||||
|
self.phase = GamePhase::Simulating;
|
||||||
|
|
||||||
|
match self.turn_team {
|
||||||
|
Team::Red => self.stones_red = self.stones_red.saturating_sub(1),
|
||||||
|
Team::Yellow => self.stones_yellow = self.stones_yellow.saturating_sub(1),
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn finish_simulation(&mut self) {
|
||||||
|
if self.phase != GamePhase::Simulating {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.active_stones = self.physics.current_stones();
|
||||||
|
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 {
|
||||||
|
self.phase = GamePhase::Playing;
|
||||||
|
self.turn_team = self.turn_team.other();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.phase = GamePhase::Scoring;
|
||||||
|
let states = self.physics.stone_states_for_scoring();
|
||||||
|
let mut by_distance: Vec<_> = states.iter()
|
||||||
|
.map(|(id, team, x, y)| {
|
||||||
|
let dx = x - HOUSE_CENTER.0;
|
||||||
|
let dy = y - HOUSE_CENTER.1;
|
||||||
|
let dist = (dx * dx + dy * dy).sqrt();
|
||||||
|
(dist, *id, *team, *x, *y)
|
||||||
|
})
|
||||||
|
.filter(|(dist, _, _, _, _)| *dist <= HOUSE_RADIUS)
|
||||||
|
.collect();
|
||||||
|
by_distance.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal));
|
||||||
|
|
||||||
|
let scoring_team: Option<Team> = by_distance.first().map(|(_, _, team, _, _)| *team);
|
||||||
|
let mut points = 0;
|
||||||
|
if let Some(team) = scoring_team {
|
||||||
|
for (_, _, t, _, _) in &by_distance {
|
||||||
|
if *t == team {
|
||||||
|
points += 1;
|
||||||
|
} else {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if points > 0 {
|
||||||
|
let team_idx = match team {
|
||||||
|
Team::Red => 0,
|
||||||
|
Team::Yellow => 1,
|
||||||
|
};
|
||||||
|
self.scores[team_idx] += points as i32;
|
||||||
|
self.hammer = team.other();
|
||||||
|
} else {
|
||||||
|
points = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.last_end_scored = Some((self.end, points, scoring_team));
|
||||||
|
self.phase = GamePhase::EndComplete;
|
||||||
|
self.end_ends_or_continue(points, scoring_team);
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
if after_regulation && !tied {
|
||||||
|
self.phase = GamePhase::GameComplete;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if after_regulation && tied {
|
||||||
|
// Extra end
|
||||||
|
}
|
||||||
|
|
||||||
|
self.end += 1;
|
||||||
|
self.stones_red = STONES_PER_TEAM;
|
||||||
|
self.stones_yellow = STONES_PER_TEAM;
|
||||||
|
self.active_stones.clear();
|
||||||
|
self.physics.reset();
|
||||||
|
self.physics.reset_stone_ids();
|
||||||
|
self.turn_team = self.hammer.other();
|
||||||
|
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 }
|
||||||
|
});
|
||||||
|
self.last_end_scored = None;
|
||||||
|
msg
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn game_state_message(&self) -> ServerMessage {
|
||||||
|
ServerMessage::GameState {
|
||||||
|
end: self.end,
|
||||||
|
scores: self.scores,
|
||||||
|
hammer: self.hammer,
|
||||||
|
turn_team: self.turn_team,
|
||||||
|
stones: self.active_stones.clone(),
|
||||||
|
phase: match self.phase {
|
||||||
|
GamePhase::Waiting => Phase::Waiting,
|
||||||
|
GamePhase::Playing => Phase::Playing,
|
||||||
|
GamePhase::Simulating => Phase::Simulating,
|
||||||
|
GamePhase::Scoring => Phase::Scoring,
|
||||||
|
GamePhase::EndComplete => Phase::EndComplete,
|
||||||
|
GamePhase::GameComplete => Phase::GameComplete,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn game_over_message(&self) -> ServerMessage {
|
||||||
|
let winner = if self.scores[0] > self.scores[1] {
|
||||||
|
Some(Team::Red)
|
||||||
|
} else if self.scores[1] > self.scores[0] {
|
||||||
|
Some(Team::Yellow)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
ServerMessage::GameOver {
|
||||||
|
scores: self.scores,
|
||||||
|
winner,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let (tx, _) = tokio::sync::broadcast::channel(256);
|
||||||
|
let mut room = Self {
|
||||||
|
id: id.to_string(),
|
||||||
|
game: Game::new(),
|
||||||
|
tx,
|
||||||
|
};
|
||||||
|
room.game.room_tx = Some(room.tx.clone());
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_red_when_requested_and_free() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prefers_yellow_when_requested_and_free() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
assert_eq!(game.add_player("p1".into(), Some(Team::Yellow)), Some(Team::Yellow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn falls_back_when_preferred_taken() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
assert_eq!(game.add_player("p1".into(), Some(Team::Red)), Some(Team::Red));
|
||||||
|
assert_eq!(game.add_player("p2".into(), Some(Team::Red)), Some(Team::Yellow));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn legacy_order_without_preference() {
|
||||||
|
let mut game = Game::new();
|
||||||
|
assert_eq!(game.add_player("p1".into(), None), Some(Team::Red));
|
||||||
|
assert_eq!(game.add_player("p2".into(), None), Some(Team::Yellow));
|
||||||
|
assert_eq!(game.add_player("p3".into(), None), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
217
backend/src/main.rs
Normal file
217
backend/src/main.rs
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
mod protocol;
|
||||||
|
mod physics;
|
||||||
|
mod game;
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
extract::{Query, State, WebSocketUpgrade},
|
||||||
|
http::StatusCode,
|
||||||
|
response::IntoResponse,
|
||||||
|
routing::{get, post},
|
||||||
|
Router,
|
||||||
|
};
|
||||||
|
use axum::extract::ws::{Message, Utf8Bytes, WebSocket};
|
||||||
|
use futures_util::{sink::SinkExt, stream::StreamExt};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
use tokio::sync::Mutex as TokioMutex;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::game::{GamePhase, Room};
|
||||||
|
use crate::protocol::*;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct AppState {
|
||||||
|
rooms: Arc<Mutex<HashMap<String, Arc<TokioMutex<Room>>>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AppState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self { rooms: Arc::new(Mutex::new(HashMap::new())) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_or_create_room(&self, room_id: &str) -> Arc<TokioMutex<Room>> {
|
||||||
|
let mut rooms = self.rooms.lock().unwrap();
|
||||||
|
if let Some(room) = rooms.get(room_id).cloned() {
|
||||||
|
return room;
|
||||||
|
}
|
||||||
|
let room = Arc::new(TokioMutex::new(Room::new(room_id)));
|
||||||
|
rooms.insert(room_id.to_string(), room.clone());
|
||||||
|
room
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct RoomQuery {
|
||||||
|
room: String,
|
||||||
|
team: Option<Team>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct NewRoomResponse {
|
||||||
|
room: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn generate_room_code() -> String {
|
||||||
|
let s = Uuid::new_v4().to_string().replace("-", "");
|
||||||
|
s.chars().take(6).collect::<String>().to_uppercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
tracing_subscriber::fmt::init();
|
||||||
|
|
||||||
|
let state = Arc::new(AppState::new());
|
||||||
|
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/", get(health))
|
||||||
|
.route("/room", post(new_room))
|
||||||
|
.route("/ws", get(ws_handler))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
||||||
|
info!("Server listening on 0.0.0.0:3000");
|
||||||
|
axum::serve(listener, app).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health() -> impl IntoResponse {
|
||||||
|
"curltastic ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn new_room(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||||
|
let room_id = generate_room_code();
|
||||||
|
state.get_or_create_room(&room_id);
|
||||||
|
(StatusCode::OK, axum::Json(NewRoomResponse { room: room_id }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn ws_handler(
|
||||||
|
ws: WebSocketUpgrade,
|
||||||
|
Query(query): Query<RoomQuery>,
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
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_arc = state.get_or_create_room(&room_id);
|
||||||
|
let player_id = Uuid::new_v4().to_string();
|
||||||
|
let player_id_for_recv = player_id.clone();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the player to the room and tell only this socket its assigned team.
|
||||||
|
let team = {
|
||||||
|
let mut room = room_arc.lock().await;
|
||||||
|
let team = room.game.add_player(player_id.clone(), preferred_team)
|
||||||
|
.unwrap_or(Team::Red);
|
||||||
|
if room.game.can_start() {
|
||||||
|
room.game.start();
|
||||||
|
}
|
||||||
|
team
|
||||||
|
};
|
||||||
|
|
||||||
|
let joined_msg = serde_json::to_string(&ServerMessage::Joined {
|
||||||
|
room: room_id.clone(),
|
||||||
|
team,
|
||||||
|
}).unwrap();
|
||||||
|
let _ = sender.send(Message::Text(Utf8Bytes::from(joined_msg))).await;
|
||||||
|
|
||||||
|
// Subscribe to broadcast and spawn the forwarding task.
|
||||||
|
let tx = {
|
||||||
|
let room = room_arc.lock().await;
|
||||||
|
room.tx.clone()
|
||||||
|
};
|
||||||
|
let mut rx = tx.subscribe();
|
||||||
|
|
||||||
|
let send_task = tokio::spawn(async move {
|
||||||
|
loop {
|
||||||
|
match rx.recv().await {
|
||||||
|
Ok(msg) => {
|
||||||
|
let text = match serde_json::to_string(&msg) {
|
||||||
|
Ok(t) => t,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
if sender.send(Message::Text(Utf8Bytes::from(text))).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Broadcast waiting/game state to everyone in the room.
|
||||||
|
{
|
||||||
|
let room = room_arc.lock().await;
|
||||||
|
if room.game.can_start() {
|
||||||
|
let state_msg = room.game.game_state_message();
|
||||||
|
let _ = room.tx.send(state_msg);
|
||||||
|
} else {
|
||||||
|
let _ = room.tx.send(ServerMessage::Waiting { message: "Waiting for other player".to_string() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let recv_room = room_arc.clone();
|
||||||
|
let recv_id = player_id_for_recv;
|
||||||
|
let recv_task = tokio::spawn(async move {
|
||||||
|
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 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 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) => {
|
||||||
|
room.tx.send(ServerMessage::Error { message: e }).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Invalid message: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
380
backend/src/physics.rs
Normal file
380
backend/src/physics.rs
Normal file
@ -0,0 +1,380 @@
|
|||||||
|
use rapier2d::prelude::*;
|
||||||
|
|
||||||
|
use crate::protocol::*;
|
||||||
|
|
||||||
|
const LINEAR_DAMPING: f32 = 0.142;
|
||||||
|
const ANGULAR_DAMPING: f32 = 0.18;
|
||||||
|
const MAX_SIM_TIME: f32 = 30.0;
|
||||||
|
const REST_SPEED: f32 = 0.04;
|
||||||
|
const REST_ANGULAR_SPEED: f32 = 0.05;
|
||||||
|
// Rotation rate of the velocity vector, in rad/s.
|
||||||
|
// Positive curl_sign = right curl -> curves toward +x when moving up-sheet.
|
||||||
|
const CURL_RATE: f32 = 0.010;
|
||||||
|
|
||||||
|
pub struct PhysicsWorld {
|
||||||
|
gravity: Vector,
|
||||||
|
integration_parameters: IntegrationParameters,
|
||||||
|
pipeline: PhysicsPipeline,
|
||||||
|
islands: IslandManager,
|
||||||
|
broad_phase: DefaultBroadPhase,
|
||||||
|
narrow_phase: NarrowPhase,
|
||||||
|
bodies: RigidBodySet,
|
||||||
|
colliders: ColliderSet,
|
||||||
|
impulse_joints: ImpulseJointSet,
|
||||||
|
multibody_joints: MultibodyJointSet,
|
||||||
|
ccd_solver: CCDSolver,
|
||||||
|
next_stone_id: u32,
|
||||||
|
stone_handles: Vec<(u32, RigidBodyHandle, Team, i8)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PhysicsWorld {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PhysicsWorld {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut integration_parameters = IntegrationParameters::default();
|
||||||
|
integration_parameters.dt = PHYSICS_DT;
|
||||||
|
integration_parameters.num_solver_iterations = 8;
|
||||||
|
|
||||||
|
let mut world = Self {
|
||||||
|
gravity: Vector::new(0.0, 0.0),
|
||||||
|
integration_parameters,
|
||||||
|
pipeline: PhysicsPipeline::new(),
|
||||||
|
islands: IslandManager::new(),
|
||||||
|
broad_phase: DefaultBroadPhase::new(),
|
||||||
|
narrow_phase: NarrowPhase::new(),
|
||||||
|
bodies: RigidBodySet::new(),
|
||||||
|
colliders: ColliderSet::new(),
|
||||||
|
impulse_joints: ImpulseJointSet::new(),
|
||||||
|
multibody_joints: MultibodyJointSet::new(),
|
||||||
|
ccd_solver: CCDSolver::new(),
|
||||||
|
next_stone_id: 1,
|
||||||
|
stone_handles: Vec::new(),
|
||||||
|
};
|
||||||
|
world.build_sheet();
|
||||||
|
world
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset(&mut self) {
|
||||||
|
self.bodies = RigidBodySet::new();
|
||||||
|
self.colliders = ColliderSet::new();
|
||||||
|
self.islands = IslandManager::new();
|
||||||
|
self.broad_phase = DefaultBroadPhase::new();
|
||||||
|
self.narrow_phase = NarrowPhase::new();
|
||||||
|
self.impulse_joints = ImpulseJointSet::new();
|
||||||
|
self.multibody_joints = MultibodyJointSet::new();
|
||||||
|
self.ccd_solver = CCDSolver::new();
|
||||||
|
self.stone_handles.clear();
|
||||||
|
self.build_sheet();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn reset_stone_ids(&mut self) {
|
||||||
|
self.next_stone_id = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_sheet(&mut self) {
|
||||||
|
let half = SHEET_WIDTH / 2.0 + 0.1;
|
||||||
|
let left = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
|
||||||
|
.translation(Vector::new(-half, SHEET_LENGTH / 2.0))
|
||||||
|
.friction(0.0)
|
||||||
|
.restitution(0.0)
|
||||||
|
.build();
|
||||||
|
self.colliders.insert(left);
|
||||||
|
|
||||||
|
let right = ColliderBuilder::cuboid(0.1, SHEET_LENGTH / 2.0 + 1.0)
|
||||||
|
.translation(Vector::new(half, SHEET_LENGTH / 2.0))
|
||||||
|
.friction(0.0)
|
||||||
|
.restitution(0.0)
|
||||||
|
.build();
|
||||||
|
self.colliders.insert(right);
|
||||||
|
|
||||||
|
let back = ColliderBuilder::cuboid(SHEET_WIDTH / 2.0 + 1.0, 0.1)
|
||||||
|
.translation(Vector::new(0.0, SHEET_LENGTH + 0.1))
|
||||||
|
.friction(0.0)
|
||||||
|
.restitution(0.1)
|
||||||
|
.build();
|
||||||
|
self.colliders.insert(back);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn throw(
|
||||||
|
&mut self,
|
||||||
|
team: Team,
|
||||||
|
broom_x: f32,
|
||||||
|
broom_y: f32,
|
||||||
|
weight: u8,
|
||||||
|
curl: i8,
|
||||||
|
friction: f32,
|
||||||
|
) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||||
|
let weight = weight.clamp(1, 10) as f32;
|
||||||
|
let t = (weight - 1.0) / 9.0;
|
||||||
|
let speed = MIN_SPEED + t * (MAX_SPEED - MIN_SPEED);
|
||||||
|
let dx = broom_x;
|
||||||
|
let dy = broom_y - HACK_Y;
|
||||||
|
let len = (dx * dx + dy * dy).sqrt().max(0.01);
|
||||||
|
let vx = dx / len * speed;
|
||||||
|
let vy = dy / len * speed;
|
||||||
|
|
||||||
|
let curl_sign = if curl < 0 { -1 } else { 1 };
|
||||||
|
let damping_mult = friction.clamp(0.5, 2.0);
|
||||||
|
|
||||||
|
self.spawn_stone(team, 0.0, HACK_Y, vx, vy, curl_sign, damping_mult)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn spawn_stone(
|
||||||
|
&mut self,
|
||||||
|
team: Team,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
vx: f32,
|
||||||
|
vy: f32,
|
||||||
|
curl_sign: i8,
|
||||||
|
damping_mult: f32,
|
||||||
|
) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||||
|
let id = self.next_stone_id;
|
||||||
|
self.next_stone_id += 1;
|
||||||
|
|
||||||
|
let body = RigidBodyBuilder::dynamic()
|
||||||
|
.translation(Vector::new(x, y))
|
||||||
|
.linvel(Vector::new(vx, vy))
|
||||||
|
.angvel(0.0)
|
||||||
|
.linear_damping(LINEAR_DAMPING * damping_mult)
|
||||||
|
.angular_damping(ANGULAR_DAMPING)
|
||||||
|
.can_sleep(false)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
let handle = self.bodies.insert(body);
|
||||||
|
|
||||||
|
let collider = ColliderBuilder::ball(STONE_RADIUS)
|
||||||
|
.friction(STONE_FRICTION)
|
||||||
|
.friction_combine_rule(CoefficientCombineRule::Average)
|
||||||
|
.restitution(STONE_RESTITUTION)
|
||||||
|
.restitution_combine_rule(CoefficientCombineRule::Average)
|
||||||
|
.density(STONE_MASS / (std::f32::consts::PI * STONE_RADIUS * STONE_RADIUS))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
self.colliders.insert_with_parent(collider, handle, &mut self.bodies);
|
||||||
|
self.stone_handles.push((id, handle, team, curl_sign));
|
||||||
|
|
||||||
|
self.simulate_until_rest(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn simulate_until_rest(&mut self, thrown_id: u32) -> Result<Vec<(f32, f32, f32)>, String> {
|
||||||
|
let mut path: Vec<(f32, f32, f32)> = Vec::new();
|
||||||
|
let sample_step = 1.0 / SAMPLE_RATE_HZ as f32;
|
||||||
|
let mut sample_accum: f32 = 0.0;
|
||||||
|
let mut time: f32 = 0.0;
|
||||||
|
|
||||||
|
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
||||||
|
let body = &self.bodies[*h];
|
||||||
|
path.push((body.translation().x, body.translation().y, time));
|
||||||
|
}
|
||||||
|
|
||||||
|
loop {
|
||||||
|
self.step();
|
||||||
|
self.apply_curl();
|
||||||
|
time += PHYSICS_DT;
|
||||||
|
sample_accum += PHYSICS_DT;
|
||||||
|
|
||||||
|
if sample_accum >= sample_step {
|
||||||
|
sample_accum -= sample_step;
|
||||||
|
if let Some((_, h, _, _)) = self.stone_handles.iter().find(|(id, _, _, _)| *id == thrown_id) {
|
||||||
|
let body = &self.bodies[*h];
|
||||||
|
path.push((body.translation().x, body.translation().y, time));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.all_stones_at_rest() || time > MAX_SIM_TIME {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.prune_out_of_play();
|
||||||
|
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rotate each stone's velocity slightly based on its selected curl direction.
|
||||||
|
// Right curl (curl_sign = +1) curves toward +x when moving up-sheet (positive y).
|
||||||
|
fn apply_curl(&mut self) {
|
||||||
|
for (_, handle, _, curl_sign) in &self.stone_handles {
|
||||||
|
let body = match self.bodies.get_mut(*handle) {
|
||||||
|
Some(b) => b,
|
||||||
|
None => continue,
|
||||||
|
};
|
||||||
|
|
||||||
|
let v = body.linvel();
|
||||||
|
let speed_sq = v.x * v.x + v.y * v.y;
|
||||||
|
let speed = speed_sq.sqrt();
|
||||||
|
if speed < 1e-4 {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let angle = -(*curl_sign as f32) * CURL_RATE * PHYSICS_DT;
|
||||||
|
let cos = angle.cos();
|
||||||
|
let sin = angle.sin();
|
||||||
|
let new_v = Vector::new(v.x * cos - v.y * sin, v.x * sin + v.y * cos);
|
||||||
|
body.set_linvel(new_v, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prune_out_of_play(&mut self) {
|
||||||
|
let mut keep = Vec::new();
|
||||||
|
for (id, handle, team, curl) in self.stone_handles.drain(..) {
|
||||||
|
if let Some(body) = self.bodies.get(handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
let beyond_back = pos.y > BACK_LINE_Y;
|
||||||
|
let short_of_hog = pos.y < HOG_LINE_Y;
|
||||||
|
let outside = pos.x.abs() > SHEET_WIDTH / 2.0;
|
||||||
|
if beyond_back || short_of_hog || outside {
|
||||||
|
self.bodies.remove(handle, &mut self.islands, &mut self.colliders, &mut self.impulse_joints, &mut self.multibody_joints, true);
|
||||||
|
} else {
|
||||||
|
keep.push((id, handle, team, curl));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.stone_handles = keep;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(&mut self) {
|
||||||
|
self.pipeline.step(
|
||||||
|
self.gravity,
|
||||||
|
&self.integration_parameters,
|
||||||
|
&mut self.islands,
|
||||||
|
&mut self.broad_phase,
|
||||||
|
&mut self.narrow_phase,
|
||||||
|
&mut self.bodies,
|
||||||
|
&mut self.colliders,
|
||||||
|
&mut self.impulse_joints,
|
||||||
|
&mut self.multibody_joints,
|
||||||
|
&mut self.ccd_solver,
|
||||||
|
&(),
|
||||||
|
&(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_stones_at_rest(&self) -> bool {
|
||||||
|
for (_, handle, _, _) in &self.stone_handles {
|
||||||
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let v = body.linvel();
|
||||||
|
let speed = (v.x * v.x + v.y * v.y).sqrt();
|
||||||
|
if speed > REST_SPEED || body.angvel().abs() > REST_ANGULAR_SPEED {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn current_stones(&self) -> Vec<StoneState> {
|
||||||
|
let mut states = Vec::new();
|
||||||
|
for (id, handle, team, _) in &self.stone_handles {
|
||||||
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
states.push(StoneState {
|
||||||
|
id: *id,
|
||||||
|
team: *team,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
rotation: body.rotation().angle(),
|
||||||
|
active: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
states
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn stone_states_for_scoring(&self) -> Vec<(u32, Team, f32, f32)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for (id, handle, team, _) in &self.stone_handles {
|
||||||
|
if let Some(body) = self.bodies.get(*handle) {
|
||||||
|
let pos = body.translation();
|
||||||
|
out.push((*id, *team, pos.x, pos.y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn final_y(world: &PhysicsWorld, id: u32) -> f32 {
|
||||||
|
world.stone_handles.iter()
|
||||||
|
.find(|(sid, _, _, _)| *sid == id)
|
||||||
|
.map(|(_, h, _, _)| {
|
||||||
|
let b = &world.bodies[*h];
|
||||||
|
b.translation().y
|
||||||
|
})
|
||||||
|
.unwrap_or(f32::NAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn final_x(world: &PhysicsWorld, id: u32) -> f32 {
|
||||||
|
world.stone_handles.iter()
|
||||||
|
.find(|(sid, _, _, _)| *sid == id)
|
||||||
|
.map(|(_, h, _, _)| {
|
||||||
|
let b = &world.bodies[*h];
|
||||||
|
b.translation().x
|
||||||
|
})
|
||||||
|
.unwrap_or(f32::NAN)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn weight_7_lands_on_tee_line() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
||||||
|
let id = world.next_stone_id - 1;
|
||||||
|
let y = final_y(&world, id);
|
||||||
|
println!("weight 7 final y={}", y);
|
||||||
|
assert!(
|
||||||
|
(y - HOUSE_CENTER.1).abs() <= 0.5,
|
||||||
|
"weight-7 draw shot should finish on the tee line, got y={}",
|
||||||
|
y
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn curl_direction_mirrors_x_offset() {
|
||||||
|
let mut right = PhysicsWorld::new();
|
||||||
|
right.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
||||||
|
let right_id = right.next_stone_id - 1;
|
||||||
|
let right_x = final_x(&right, right_id);
|
||||||
|
|
||||||
|
let mut left = PhysicsWorld::new();
|
||||||
|
left.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
|
||||||
|
let left_id = left.next_stone_id - 1;
|
||||||
|
let left_x = final_x(&left, left_id);
|
||||||
|
|
||||||
|
println!("right curl final x={} left curl final x={}", right_x, left_x);
|
||||||
|
assert!(
|
||||||
|
right_x > left_x + 0.05,
|
||||||
|
"right curl should finish to the right of left curl: right={} left={}",
|
||||||
|
right_x,
|
||||||
|
left_x
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stones_persist_after_multiple_throws() {
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, 1, 1.0).unwrap();
|
||||||
|
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 7, -1, 1.0).unwrap();
|
||||||
|
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert_eq!(stones.len(), 2, "both stones should remain in the physics world");
|
||||||
|
assert_eq!(stones[0].id, 1);
|
||||||
|
assert_eq!(stones[1].id, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn out_of_play_stone_is_pruned() {
|
||||||
|
// A very light, high-friction throw should stop short of the hog line and be removed.
|
||||||
|
let mut world = PhysicsWorld::new();
|
||||||
|
world.throw(Team::Red, 0.0, HOUSE_CENTER.1, 1, 0, 2.0).unwrap();
|
||||||
|
let stones = world.current_stones();
|
||||||
|
assert!(stones.is_empty(), "stones short of the hog line should be pruned");
|
||||||
|
}
|
||||||
|
}
|
||||||
126
backend/src/protocol.rs
Normal file
126
backend/src/protocol.rs
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
pub const TICK_RATE_HZ: u16 = 120;
|
||||||
|
pub const SAMPLE_RATE_HZ: u16 = 40;
|
||||||
|
pub const PHYSICS_DT: f32 = 1.0 / TICK_RATE_HZ as f32;
|
||||||
|
|
||||||
|
pub const STONES_PER_TEAM: u8 = 8;
|
||||||
|
pub const ENDS: u8 = 10;
|
||||||
|
|
||||||
|
// World coordinates in meters, y along sheet toward house.
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Stone physical properties
|
||||||
|
pub const STONE_RADIUS: f32 = 0.15;
|
||||||
|
pub const STONE_MASS: f32 = 20.0;
|
||||||
|
pub const STONE_FRICTION: f32 = 0.015;
|
||||||
|
pub const STONE_RESTITUTION: f32 = 0.05;
|
||||||
|
pub const MIN_SPEED: f32 = 3.0;
|
||||||
|
pub const MAX_SPEED: f32 = 6.45;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Team {
|
||||||
|
#[default]
|
||||||
|
Red,
|
||||||
|
Yellow,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Team {
|
||||||
|
pub fn other(self) -> Self {
|
||||||
|
match self {
|
||||||
|
Team::Red => Team::Yellow,
|
||||||
|
Team::Yellow => Team::Red,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Team {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
match self {
|
||||||
|
Team::Red => write!(f, "red"),
|
||||||
|
Team::Yellow => write!(f, "yellow"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ClientMessage {
|
||||||
|
Throw {
|
||||||
|
broom_x: f32,
|
||||||
|
broom_y: f32,
|
||||||
|
weight: u8,
|
||||||
|
#[serde(default = "default_curl")]
|
||||||
|
curl: i8,
|
||||||
|
#[serde(default = "default_friction")]
|
||||||
|
friction: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_curl() -> i8 { 1 }
|
||||||
|
fn default_friction() -> f32 { 1.0 }
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type", rename_all = "snake_case")]
|
||||||
|
pub enum ServerMessage {
|
||||||
|
Joined { room: String, team: Team },
|
||||||
|
Waiting { message: String },
|
||||||
|
GameState {
|
||||||
|
end: u8,
|
||||||
|
scores: [i32; 2], // red, yellow
|
||||||
|
hammer: Team,
|
||||||
|
turn_team: Team,
|
||||||
|
stones: Vec<StoneState>,
|
||||||
|
phase: Phase,
|
||||||
|
},
|
||||||
|
Trajectory {
|
||||||
|
path: Vec<(f32, f32, f32)>,
|
||||||
|
},
|
||||||
|
EndScored { end: u8, points: i32, scoring_team: Option<Team> },
|
||||||
|
GameOver {
|
||||||
|
scores: [i32; 2],
|
||||||
|
winner: Option<Team>,
|
||||||
|
},
|
||||||
|
Error { message: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum Phase {
|
||||||
|
#[default]
|
||||||
|
Waiting,
|
||||||
|
Playing,
|
||||||
|
Simulating,
|
||||||
|
Scoring,
|
||||||
|
EndComplete,
|
||||||
|
GameComplete,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct StoneState {
|
||||||
|
pub id: u32,
|
||||||
|
pub team: Team,
|
||||||
|
pub x: f32,
|
||||||
|
pub y: f32,
|
||||||
|
pub rotation: f32,
|
||||||
|
pub active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct Player {
|
||||||
|
pub id: String,
|
||||||
|
pub team: Team,
|
||||||
|
pub connected: bool,
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user