forked from eros/curltastic
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