diff --git a/Cargo.lock b/Cargo.lock index b494dfc..24f5826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -155,6 +155,7 @@ dependencies = [ "breakpoint-golf", "breakpoint-lasertag", "breakpoint-platformer", + "breakpoint-tron", "console_error_panic_hook", "fastrand", "getrandom 0.3.4", @@ -259,6 +260,7 @@ dependencies = [ "breakpoint-golf", "breakpoint-lasertag", "breakpoint-platformer", + "breakpoint-tron", "bytes", "futures", "hex", @@ -279,6 +281,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "breakpoint-tron" +version = "0.1.0" +dependencies = [ + "breakpoint-core", + "proptest", + "rand", + "rmp-serde", + "serde", + "serde_json", + "toml", + "tracing", +] + [[package]] name = "brotli" version = "8.0.2" diff --git a/Cargo.toml b/Cargo.toml index 63aa058..28d33ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,6 +8,7 @@ members = [ "crates/games/breakpoint-golf", "crates/games/breakpoint-platformer", "crates/games/breakpoint-lasertag", + "crates/games/breakpoint-tron", "crates/adapters/breakpoint-github", ] diff --git a/crates/breakpoint-client/Cargo.toml b/crates/breakpoint-client/Cargo.toml index f02d729..2665b98 100644 --- a/crates/breakpoint-client/Cargo.toml +++ b/crates/breakpoint-client/Cargo.toml @@ -11,16 +11,18 @@ authors.workspace = true crate-type = ["cdylib", "rlib"] [features] -default = ["golf", "platformer", "lasertag"] +default = ["golf", "platformer", "lasertag", "tron"] golf = ["dep:breakpoint-golf"] platformer = ["dep:breakpoint-platformer"] lasertag = ["dep:breakpoint-lasertag"] +tron = ["dep:breakpoint-tron"] [dependencies] breakpoint-core = { path = "../breakpoint-core" } breakpoint-golf = { path = "../games/breakpoint-golf", optional = true } breakpoint-platformer = { path = "../games/breakpoint-platformer", optional = true } breakpoint-lasertag = { path = "../games/breakpoint-lasertag", optional = true } +breakpoint-tron = { path = "../games/breakpoint-tron", optional = true } serde.workspace = true serde_json.workspace = true rmp-serde.workspace = true diff --git a/crates/breakpoint-client/src/app.rs b/crates/breakpoint-client/src/app.rs index 8091645..fb46c99 100644 --- a/crates/breakpoint-client/src/app.rs +++ b/crates/breakpoint-client/src/app.rs @@ -541,6 +541,25 @@ impl App { GameId::LaserTag => { self.camera.set_mode(CameraMode::LaserTagFixed); }, + #[cfg(feature = "tron")] + GameId::Tron => { + if let Some(ref role) = self.network_role + && let Some(s) = read_game_state::(active) + && let Some(c) = s.players.get(&role.local_player_id) + && c.alive + { + let dir = match c.direction { + breakpoint_tron::Direction::North => [0.0, -1.0], + breakpoint_tron::Direction::South => [0.0, 1.0], + breakpoint_tron::Direction::East => [1.0, 0.0], + breakpoint_tron::Direction::West => [-1.0, 0.0], + }; + self.camera.set_mode(CameraMode::TronFollow { + cycle_pos: glam::Vec3::new(c.x, 0.0, c.z), + direction: dir, + }); + } + }, #[allow(unreachable_patterns)] _ => {}, } @@ -590,6 +609,10 @@ impl App { &self.ws, ); }, + #[cfg(feature = "tron")] + GameId::Tron => { + crate::game::tron_input::process_tron_input(&self.input, active, role, &self.ws); + }, #[allow(unreachable_patterns)] _ => {}, } @@ -632,6 +655,10 @@ impl App { dt, ); }, + #[cfg(feature = "tron")] + GameId::Tron => { + crate::game::tron_render::sync_tron_scene(&mut self.scene, active, &self.theme, dt); + }, #[allow(unreachable_patterns)] _ => {}, } diff --git a/crates/breakpoint-client/src/camera_gl.rs b/crates/breakpoint-client/src/camera_gl.rs index f31ce07..34a9815 100644 --- a/crates/breakpoint-client/src/camera_gl.rs +++ b/crates/breakpoint-client/src/camera_gl.rs @@ -9,6 +9,11 @@ pub enum CameraMode { PlatformerFollow { player_pos: Vec2 }, /// Laser tag: fixed top-down view. LaserTagFixed, + /// Tron: third-person chase camera behind the player's cycle. + TronFollow { + cycle_pos: Vec3, + direction: [f32; 2], + }, /// Overview of the course (golf fallback). GolfOverview { center_x: f32, @@ -106,6 +111,36 @@ impl Camera { self.target = Vec3::new(25.0, 0.0, 25.0); self.up = Vec3::Z; }, + CameraMode::TronFollow { + cycle_pos, + direction, + } => { + // Chase camera behind the cycle + let cam_height = 18.0; + let cam_behind = 25.0; + let look_ahead = 15.0; + + let dir_x = direction[0]; + let dir_z = direction[1]; + + // Camera behind the cycle + let target_pos = Vec3::new( + cycle_pos.x - dir_x * cam_behind, + cam_height, + cycle_pos.z - dir_z * cam_behind, + ); + // Look ahead of the cycle + let look_at = Vec3::new( + cycle_pos.x + dir_x * look_ahead, + 0.0, + cycle_pos.z + dir_z * look_ahead, + ); + + self.position = self.position.lerp(target_pos, lerp_factor); + self.target = self.target.lerp(look_at, lerp_factor); + self.up = Vec3::Y; + self.far = 600.0; + }, CameraMode::None => {}, } } diff --git a/crates/breakpoint-client/src/game/mod.rs b/crates/breakpoint-client/src/game/mod.rs index 826b774..b299984 100644 --- a/crates/breakpoint-client/src/game/mod.rs +++ b/crates/breakpoint-client/src/game/mod.rs @@ -10,6 +10,10 @@ pub mod lasertag_render; pub mod platformer_input; #[cfg(feature = "platformer")] pub mod platformer_render; +#[cfg(feature = "tron")] +pub mod tron_input; +#[cfg(feature = "tron")] +pub mod tron_render; use std::collections::HashMap; @@ -53,6 +57,11 @@ pub fn create_registry() -> GameRegistry { registry.register(GameId::LaserTag, || { Box::new(breakpoint_lasertag::LaserTagArena::new()) }); + #[cfg(feature = "tron")] + registry.register( + GameId::Tron, + || Box::new(breakpoint_tron::TronCycles::new()), + ); registry } diff --git a/crates/breakpoint-client/src/game/tron_input.rs b/crates/breakpoint-client/src/game/tron_input.rs new file mode 100644 index 0000000..d7530d3 --- /dev/null +++ b/crates/breakpoint-client/src/game/tron_input.rs @@ -0,0 +1,28 @@ +use breakpoint_tron::{TronInput, TurnDirection}; + +use crate::app::{ActiveGame, NetworkRole}; +use crate::game::send_player_input; +use crate::input::InputState; +use crate::net_client::WsClient; + +/// Process tron input: A/D or Left/Right for turning, Space for brake. +pub fn process_tron_input( + input: &InputState, + active: &mut ActiveGame, + role: &NetworkRole, + ws: &WsClient, +) { + let turn = if input.is_key_just_pressed("KeyA") || input.is_key_just_pressed("ArrowLeft") { + TurnDirection::Left + } else if input.is_key_just_pressed("KeyD") || input.is_key_just_pressed("ArrowRight") { + TurnDirection::Right + } else { + TurnDirection::None + }; + + let brake = + input.is_key_down("Space") || input.is_key_down("KeyS") || input.is_key_down("ArrowDown"); + + let tron_input = TronInput { turn, brake }; + send_player_input(&tron_input, active, role, ws); +} diff --git a/crates/breakpoint-client/src/game/tron_render.rs b/crates/breakpoint-client/src/game/tron_render.rs new file mode 100644 index 0000000..889ecf0 --- /dev/null +++ b/crates/breakpoint-client/src/game/tron_render.rs @@ -0,0 +1,224 @@ +use glam::{Vec3, Vec4}; + +use crate::app::ActiveGame; +use crate::game::read_game_state; +use crate::scene::{MaterialType, MeshType, Scene, Transform}; +use crate::theme::Theme; + +/// Pure black floor. +const FLOOR_COLOR: Vec4 = Vec4::new(0.0, 0.0, 0.0, 1.0); +/// Bright neon grid lines. +const GRID_COLOR: Vec4 = Vec4::new(0.0, 0.6, 0.4, 1.0); +/// Boundary wall color. +const BOUNDARY_COLOR: Vec4 = Vec4::new(0.3, 0.5, 1.0, 1.0); +const WIN_ZONE_COLOR: Vec4 = Vec4::new(1.0, 0.85, 0.2, 0.7); + +/// Player trail colors (neon palette). +const PLAYER_COLORS: [Vec4; 8] = [ + Vec4::new(0.0, 0.8, 1.0, 1.0), // cyan + Vec4::new(1.0, 0.3, 0.1, 1.0), // orange-red + Vec4::new(0.2, 1.0, 0.3, 1.0), // green + Vec4::new(1.0, 0.1, 0.8, 1.0), // magenta + Vec4::new(1.0, 1.0, 0.1, 1.0), // yellow + Vec4::new(0.5, 0.2, 1.0, 1.0), // purple + Vec4::new(1.0, 0.5, 0.5, 1.0), // pink + Vec4::new(0.3, 0.8, 0.8, 1.0), // teal +]; + +/// Sync the 3D scene with the current tron game state. +pub fn sync_tron_scene(scene: &mut Scene, active: &ActiveGame, _theme: &Theme, _dt: f32) { + let state: Option = read_game_state(active); + let Some(state) = state else { + return; + }; + + scene.clear(); + + let arena_w = state.arena_width; + let arena_d = state.arena_depth; + + // Arena floor (pure black) + scene.add( + MeshType::Plane, + MaterialType::Unlit { color: FLOOR_COLOR }, + Transform::from_xyz(arena_w / 2.0, 0.0, arena_d / 2.0) + .with_scale(Vec3::new(arena_w, 1.0, arena_d)), + ); + + // Grid lines — thick and bright + let grid_spacing = 25.0; + let grid_height = 0.02; + let grid_thickness = 0.6; + let grid_intensity = 1.2; + + // Vertical grid lines (along Z axis) + let mut x = grid_spacing; + while x < arena_w { + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: GRID_COLOR, + intensity: grid_intensity, + }, + Transform::from_xyz(x, grid_height, arena_d / 2.0).with_scale(Vec3::new( + grid_thickness, + 0.04, + arena_d, + )), + ); + x += grid_spacing; + } + + // Horizontal grid lines (along X axis) + let mut z = grid_spacing; + while z < arena_d { + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: GRID_COLOR, + intensity: grid_intensity, + }, + Transform::from_xyz(arena_w / 2.0, grid_height, z).with_scale(Vec3::new( + arena_w, + 0.04, + grid_thickness, + )), + ); + z += grid_spacing; + } + + // Arena boundary walls + let wall_height = 4.0; + let wall_thickness = 0.5; + let boundary_intensity = 2.0; + + // North wall (z=0) + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: BOUNDARY_COLOR, + intensity: boundary_intensity, + }, + Transform::from_xyz(arena_w / 2.0, wall_height / 2.0, 0.0).with_scale(Vec3::new( + arena_w, + wall_height, + wall_thickness, + )), + ); + // South wall (z=depth) + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: BOUNDARY_COLOR, + intensity: boundary_intensity, + }, + Transform::from_xyz(arena_w / 2.0, wall_height / 2.0, arena_d).with_scale(Vec3::new( + arena_w, + wall_height, + wall_thickness, + )), + ); + // West wall (x=0) + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: BOUNDARY_COLOR, + intensity: boundary_intensity, + }, + Transform::from_xyz(0.0, wall_height / 2.0, arena_d / 2.0).with_scale(Vec3::new( + wall_thickness, + wall_height, + arena_d, + )), + ); + // East wall (x=width) + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color: BOUNDARY_COLOR, + intensity: boundary_intensity, + }, + Transform::from_xyz(arena_w, wall_height / 2.0, arena_d / 2.0).with_scale(Vec3::new( + wall_thickness, + wall_height, + arena_d, + )), + ); + + // Build a player index for color mapping + let mut player_index: std::collections::HashMap = std::collections::HashMap::new(); + for (i, (&pid, _)) in state.players.iter().enumerate() { + player_index.insert(pid, i); + } + + // Wall trail segments — solid colored walls + let trail_height = 3.0; + let trail_thickness = 0.8; + for wall in &state.wall_segments { + let dx = wall.x2 - wall.x1; + let dz = wall.z2 - wall.z1; + let len = (dx * dx + dz * dz).sqrt(); + if len < 0.1 { + continue; + } + + let cx = (wall.x1 + wall.x2) / 2.0; + let cz = (wall.z1 + wall.z2) / 2.0; + + let color_idx = + player_index.get(&wall.owner_id).copied().unwrap_or(0) % PLAYER_COLORS.len(); + let color = PLAYER_COLORS[color_idx]; + + // Determine if horizontal or vertical + let is_horizontal = dz.abs() < 0.1; + let scale = if is_horizontal { + Vec3::new(len, trail_height, trail_thickness) + } else { + Vec3::new(trail_thickness, trail_height, len) + }; + + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color, + intensity: 2.5, + }, + Transform::from_xyz(cx, trail_height / 2.0, cz).with_scale(scale), + ); + } + + // Cycle heads (bright cubes at the head of each trail) + for (&pid, cycle) in &state.players { + if !cycle.alive { + continue; + } + let color_idx = player_index.get(&pid).copied().unwrap_or(0) % PLAYER_COLORS.len(); + let color = PLAYER_COLORS[color_idx]; + + scene.add( + MeshType::Cuboid, + MaterialType::Glow { + color, + intensity: 4.0, + }, + Transform::from_xyz(cycle.x, 1.5, cycle.z).with_scale(Vec3::new(1.5, 3.0, 1.5)), + ); + } + + // Win zone (expanding golden circle) + if state.win_zone.active { + scene.add( + MeshType::Cylinder { segments: 24 }, + MaterialType::Ripple { + color: WIN_ZONE_COLOR, + ring_count: 3.0, + speed: 2.0, + }, + Transform::from_xyz(state.win_zone.x, 0.05, state.win_zone.z).with_scale(Vec3::new( + state.win_zone.radius * 2.0, + 0.1, + state.win_zone.radius * 2.0, + )), + ); + } +} diff --git a/crates/breakpoint-core/src/game_trait.rs b/crates/breakpoint-core/src/game_trait.rs index 104631a..e3a490b 100644 --- a/crates/breakpoint-core/src/game_trait.rs +++ b/crates/breakpoint-core/src/game_trait.rs @@ -14,6 +14,7 @@ pub enum GameId { Golf, Platformer, LaserTag, + Tron, } impl GameId { @@ -23,6 +24,7 @@ impl GameId { Self::Golf => "mini-golf", Self::Platformer => "platform-racer", Self::LaserTag => "laser-tag", + Self::Tron => "tron", } } @@ -32,6 +34,7 @@ impl GameId { "mini-golf" => Some(Self::Golf), "platform-racer" => Some(Self::Platformer), "laser-tag" => Some(Self::LaserTag), + "tron" => Some(Self::Tron), _ => None, } } diff --git a/crates/breakpoint-server/Cargo.toml b/crates/breakpoint-server/Cargo.toml index 4d83080..f95648f 100644 --- a/crates/breakpoint-server/Cargo.toml +++ b/crates/breakpoint-server/Cargo.toml @@ -8,10 +8,11 @@ repository.workspace = true authors.workspace = true [features] -default = ["golf", "platformer", "lasertag"] +default = ["golf", "platformer", "lasertag", "tron"] golf = ["dep:breakpoint-golf"] platformer = ["dep:breakpoint-platformer"] lasertag = ["dep:breakpoint-lasertag"] +tron = ["dep:breakpoint-tron"] github-poller = ["dep:breakpoint-github"] [dependencies] @@ -20,6 +21,7 @@ breakpoint-github = { path = "../adapters/breakpoint-github", optional = true } breakpoint-golf = { path = "../games/breakpoint-golf", optional = true } breakpoint-platformer = { path = "../games/breakpoint-platformer", optional = true } breakpoint-lasertag = { path = "../games/breakpoint-lasertag", optional = true } +breakpoint-tron = { path = "../games/breakpoint-tron", optional = true } axum = { version = "0.8", features = ["ws"] } tokio.workspace = true serde.workspace = true @@ -49,6 +51,7 @@ serde_json.workspace = true breakpoint-golf = { path = "../games/breakpoint-golf" } breakpoint-platformer = { path = "../games/breakpoint-platformer" } breakpoint-lasertag = { path = "../games/breakpoint-lasertag" } +breakpoint-tron = { path = "../games/breakpoint-tron" } breakpoint-core = { path = "../breakpoint-core", features = ["test-helpers"] } rmp-serde.workspace = true diff --git a/crates/breakpoint-server/src/game_loop.rs b/crates/breakpoint-server/src/game_loop.rs index 95456dd..8a15ba5 100644 --- a/crates/breakpoint-server/src/game_loop.rs +++ b/crates/breakpoint-server/src/game_loop.rs @@ -77,6 +77,11 @@ impl ServerGameRegistry { self.factories.insert(GameId::LaserTag, || { Box::new(breakpoint_lasertag::LaserTagArena::new()) }); + #[cfg(feature = "tron")] + self.factories.insert( + GameId::Tron, + || Box::new(breakpoint_tron::TronCycles::new()), + ); } pub fn create(&self, game_id: GameId) -> Option> { diff --git a/crates/games/breakpoint-tron/Cargo.toml b/crates/games/breakpoint-tron/Cargo.toml new file mode 100644 index 0000000..dd56f6a --- /dev/null +++ b/crates/games/breakpoint-tron/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "breakpoint-tron" +description = "Tron Light Cycles game for Breakpoint" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +authors.workspace = true + +[dependencies] +breakpoint-core = { path = "../../breakpoint-core" } +serde = { workspace = true } +serde_json.workspace = true +rmp-serde.workspace = true +rand.workspace = true +tracing.workspace = true +toml.workspace = true + +[dev-dependencies] +breakpoint-core = { path = "../../breakpoint-core", features = ["test-helpers"] } +proptest.workspace = true + +[lints] +workspace = true diff --git a/crates/games/breakpoint-tron/src/arena.rs b/crates/games/breakpoint-tron/src/arena.rs new file mode 100644 index 0000000..76d8ae1 --- /dev/null +++ b/crates/games/breakpoint-tron/src/arena.rs @@ -0,0 +1,58 @@ +use serde::{Deserialize, Serialize}; + +/// A spawn position with starting direction. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SpawnPoint { + pub x: f32, + pub z: f32, + pub direction: super::Direction, +} + +/// Arena definition. +#[derive(Debug, Clone)] +pub struct Arena { + pub width: f32, + pub depth: f32, + pub spawn_points: Vec, +} + +/// Generate spawn positions for N players evenly distributed around the arena perimeter, +/// facing inward. +pub fn create_arena(width: f32, depth: f32, player_count: usize) -> Arena { + let mut spawn_points = Vec::with_capacity(player_count); + let margin = 20.0; + let cx = width / 2.0; + let cz = depth / 2.0; + + for i in 0..player_count { + let angle = std::f32::consts::TAU * (i as f32) / (player_count.max(1) as f32); + + // Place on a circle inside the arena + let radius = (width.min(depth) / 2.0) - margin; + let x = cx + radius * angle.cos(); + let z = cz + radius * angle.sin(); + + // Face inward (toward center) + let dx = cx - x; + let dz = cz - z; + let direction = if dx.abs() > dz.abs() { + if dx > 0.0 { + super::Direction::East + } else { + super::Direction::West + } + } else if dz > 0.0 { + super::Direction::South + } else { + super::Direction::North + }; + + spawn_points.push(SpawnPoint { x, z, direction }); + } + + Arena { + width, + depth, + spawn_points, + } +} diff --git a/crates/games/breakpoint-tron/src/collision.rs b/crates/games/breakpoint-tron/src/collision.rs new file mode 100644 index 0000000..8d296cd --- /dev/null +++ b/crates/games/breakpoint-tron/src/collision.rs @@ -0,0 +1,204 @@ +use breakpoint_core::game_trait::PlayerId; + +use super::{CycleState, Direction, WallSegment}; +use crate::config::TronConfig; + +/// Result of a collision check. +pub struct CollisionResult { + /// Whether the cycle is still alive after this check. + pub alive: bool, + /// If killed, whose wall caused the death (None = arena boundary or own wall). + pub killer_id: Option, + /// Whether it was a suicide (hit own wall). + pub is_suicide: bool, +} + +/// Check if a cycle collides with arena boundaries. +pub fn check_arena_boundary(cycle: &CycleState, arena_width: f32, arena_depth: f32) -> bool { + let margin = 0.1; + cycle.x <= margin + || cycle.x >= arena_width - margin + || cycle.z <= margin + || cycle.z >= arena_depth - margin +} + +/// Check if a cycle collides with any wall segment. +/// Returns the CollisionResult with killer info. +pub fn check_wall_collision( + cycle: &CycleState, + cycle_owner_id: PlayerId, + walls: &[WallSegment], + config: &TronConfig, +) -> CollisionResult { + let col_dist = config.collision_distance; + + for wall in walls { + // Skip the active segment of our own trail (the one currently being drawn) + if wall.owner_id == cycle_owner_id && wall.is_active { + continue; + } + + // Skip own segments whose endpoint is at the cycle's position (turn corners). + // At low speeds the cycle may still be within collision distance of the + // just-closed segment after a turn. + if wall.owner_id == cycle_owner_id { + let ex = cycle.x - wall.x2; + let ez = cycle.z - wall.z2; + if (ex * ex + ez * ez).sqrt() < col_dist * 3.0 { + continue; + } + } + + let dist = point_to_segment_distance(cycle.x, cycle.z, wall.x1, wall.z1, wall.x2, wall.z2); + + if dist < col_dist { + let is_suicide = wall.owner_id == cycle_owner_id; + let killer_id = if is_suicide { + None + } else { + Some(wall.owner_id) + }; + return CollisionResult { + alive: false, + killer_id, + is_suicide, + }; + } + } + + CollisionResult { + alive: true, + killer_id: None, + is_suicide: false, + } +} + +/// Distance from point (px, pz) to line segment (x1, z1)-(x2, z2). +pub fn point_to_segment_distance(px: f32, pz: f32, x1: f32, z1: f32, x2: f32, z2: f32) -> f32 { + let dx = x2 - x1; + let dz = z2 - z1; + let len_sq = dx * dx + dz * dz; + + if len_sq < 1e-8 { + // Degenerate segment (point) + let ddx = px - x1; + let ddz = pz - z1; + return (ddx * ddx + ddz * ddz).sqrt(); + } + + // Project point onto segment, clamped to [0, 1] + let t = ((px - x1) * dx + (pz - z1) * dz) / len_sq; + let t = t.clamp(0.0, 1.0); + + let nearest_x = x1 + t * dx; + let nearest_z = z1 + t * dz; + + let ddx = px - nearest_x; + let ddz = pz - nearest_z; + (ddx * ddx + ddz * ddz).sqrt() +} + +/// Find the minimum distance from a cycle to any parallel wall segment within +/// the grind threshold. Returns the minimum distance, or None if no wall is near. +/// Skips the querying cycle's own active segment to avoid self-grinding. +pub fn nearest_wall_distance( + cycle: &CycleState, + cycle_owner_id: PlayerId, + walls: &[WallSegment], + arena_width: f32, + arena_depth: f32, + threshold: f32, +) -> Option { + let mut min_dist = f32::MAX; + + // Check arena boundary walls + let boundary_dists = [ + cycle.x, // left wall + arena_width - cycle.x, // right wall + cycle.z, // top wall + arena_depth - cycle.z, // bottom wall + ]; + for d in boundary_dists { + if d < threshold && d < min_dist { + min_dist = d; + } + } + + // Check trail walls (only parallel ones for grinding) + for wall in walls { + // Skip own active segment (the one currently being drawn) + if wall.owner_id == cycle_owner_id && wall.is_active { + continue; + } + + let is_parallel = match cycle.direction { + Direction::North | Direction::South => { + // Cycle moving vertically, check vertical walls (same x) + (wall.x1 - wall.x2).abs() < 0.1 + }, + Direction::East | Direction::West => { + // Cycle moving horizontally, check horizontal walls (same z) + (wall.z1 - wall.z2).abs() < 0.1 + }, + }; + + if !is_parallel { + continue; + } + + let dist = point_to_segment_distance(cycle.x, cycle.z, wall.x1, wall.z1, wall.x2, wall.z2); + if dist < threshold && dist < min_dist { + min_dist = dist; + } + } + + if min_dist < f32::MAX { + Some(min_dist) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_to_segment_horizontal() { + // Horizontal segment from (0,0) to (10,0), point at (5, 3) + let d = point_to_segment_distance(5.0, 3.0, 0.0, 0.0, 10.0, 0.0); + assert!((d - 3.0).abs() < 0.01); + } + + #[test] + fn point_to_segment_endpoint() { + // Point nearest to endpoint + let d = point_to_segment_distance(12.0, 0.0, 0.0, 0.0, 10.0, 0.0); + assert!((d - 2.0).abs() < 0.01); + } + + #[test] + fn point_to_segment_degenerate() { + let d = point_to_segment_distance(3.0, 4.0, 0.0, 0.0, 0.0, 0.0); + assert!((d - 5.0).abs() < 0.01); + } + + #[test] + fn arena_boundary_detection() { + let cycle = CycleState { + x: 0.05, + z: 250.0, + direction: Direction::West, + speed: 20.0, + rubber: 0.5, + brake_fuel: 3.0, + alive: true, + trail_start_index: 0, + turn_cooldown: 0.0, + kills: 0, + died: false, + is_suicide: false, + }; + assert!(check_arena_boundary(&cycle, 500.0, 500.0)); + } +} diff --git a/crates/games/breakpoint-tron/src/config.rs b/crates/games/breakpoint-tron/src/config.rs new file mode 100644 index 0000000..bb15ce7 --- /dev/null +++ b/crates/games/breakpoint-tron/src/config.rs @@ -0,0 +1,95 @@ +use serde::{Deserialize, Serialize}; + +/// Data-driven configuration for the Tron game. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default)] +pub struct TronConfig { + /// Base cycle speed (units/s). + pub base_speed: f32, + /// Maximum speed a cycle can reach via wall acceleration. + pub max_speed: f32, + /// Wall acceleration threshold distance (units). Walls within this distance boost speed. + pub grind_distance: f32, + /// Maximum speed bonus multiplier from wall grinding (e.g. 2.0 = 2x base speed). + pub grind_max_multiplier: f32, + /// Speed penalty fraction per turn (e.g. 0.05 = 5% reduction). + pub turn_speed_penalty: f32, + /// Minimum delay between turns (seconds). + pub turn_delay: f32, + /// Initial brake fuel. + pub brake_fuel_max: f32, + /// Brake fuel consumption rate per second. + pub brake_drain_rate: f32, + /// Brake fuel regeneration rate per second (when not braking). + pub brake_regen_rate: f32, + /// Brake speed multiplier (e.g. 0.5 = half speed while braking). + pub brake_speed_mult: f32, + /// Rubber amount: distance buffer before wall contact kills. + pub rubber_max: f32, + /// Rubber consumption rate when approaching walls head-on. + pub rubber_drain_rate: f32, + /// Arena width. + pub arena_width: f32, + /// Arena depth. + pub arena_depth: f32, + /// Round duration in seconds (game config). + pub round_duration_secs: f32, + /// Number of rounds per match. + pub round_count: u8, + /// Win zone appear delay (seconds since round start). + pub win_zone_delay: f32, + /// Time since last death before win zone can appear (seconds). + pub win_zone_death_delay: f32, + /// Win zone expansion rate (units/s). + pub win_zone_expand_rate: f32, + /// Speed decay rate toward base speed (units/s/s). + pub speed_decay_rate: f32, + /// Collision distance for cycle-to-wall checks. + pub collision_distance: f32, +} + +impl Default for TronConfig { + fn default() -> Self { + Self { + base_speed: 20.0, + max_speed: 60.0, + grind_distance: 6.0, + grind_max_multiplier: 2.0, + turn_speed_penalty: 0.05, + turn_delay: 0.1, + brake_fuel_max: 3.0, + brake_drain_rate: 1.0, + brake_regen_rate: 0.5, + brake_speed_mult: 0.5, + rubber_max: 0.5, + rubber_drain_rate: 10.0, + arena_width: 500.0, + arena_depth: 500.0, + round_duration_secs: 120.0, + round_count: 10, + win_zone_delay: 60.0, + win_zone_death_delay: 30.0, + win_zone_expand_rate: 5.0, + speed_decay_rate: 10.0, + collision_distance: 0.5, + } + } +} + +impl TronConfig { + /// Load config from environment or TOML file, falling back to defaults. + pub fn load() -> Self { + if let Ok(path) = std::env::var("BREAKPOINT_TRON_CONFIG") + && let Ok(contents) = std::fs::read_to_string(&path) + && let Ok(config) = toml::from_str::(&contents) + { + return config; + } + if let Ok(contents) = std::fs::read_to_string("config/tron.toml") + && let Ok(config) = toml::from_str::(&contents) + { + return config; + } + Self::default() + } +} diff --git a/crates/games/breakpoint-tron/src/lib.rs b/crates/games/breakpoint-tron/src/lib.rs new file mode 100644 index 0000000..c393826 --- /dev/null +++ b/crates/games/breakpoint-tron/src/lib.rs @@ -0,0 +1,940 @@ +pub mod arena; +pub mod collision; +pub mod config; +pub mod physics; +pub mod scoring; +pub mod win_zone; + +use std::collections::HashMap; +use std::time::Duration; + +use serde::{Deserialize, Serialize}; + +use breakpoint_core::breakpoint_game_boilerplate; +use breakpoint_core::game_trait::{ + BreakpointGame, GameConfig, GameEvent, GameMetadata, PlayerId, PlayerInputs, PlayerScore, +}; +use breakpoint_core::player::Player; + +use config::TronConfig; +use win_zone::WinZone; + +/// Cardinal direction on the 2D grid. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum Direction { + North, + South, + East, + West, +} + +/// Turn direction input. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum TurnDirection { + None, + Left, + Right, +} + +/// A wall segment left behind by a cycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WallSegment { + pub x1: f32, + pub z1: f32, + pub x2: f32, + pub z2: f32, + pub owner_id: PlayerId, + /// Whether this is the actively-extending segment (the cycle's current tail). + pub is_active: bool, +} + +/// State of a single cycle. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CycleState { + pub x: f32, + pub z: f32, + pub direction: Direction, + pub speed: f32, + pub rubber: f32, + pub brake_fuel: f32, + pub alive: bool, + /// Index into the wall_segments vec where this cycle's trail starts. + pub trail_start_index: usize, + pub turn_cooldown: f32, + /// Tracking: how many opponents died to this cycle's walls. + pub kills: u32, + pub died: bool, + pub is_suicide: bool, +} + +/// Input from a tron player. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronInput { + pub turn: TurnDirection, + pub brake: bool, +} + +impl Default for TronInput { + fn default() -> Self { + Self { + turn: TurnDirection::None, + brake: false, + } + } +} + +/// Serializable game state for network broadcast. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TronState { + pub players: HashMap, + pub wall_segments: Vec, + pub round_timer: f32, + pub round_complete: bool, + pub round_number: u8, + pub scores: HashMap, + pub win_zone: WinZone, + pub alive_count: u32, + pub arena_width: f32, + pub arena_depth: f32, + pub time_since_last_death: f32, + pub winner_id: Option, +} + +/// The Tron Light Cycles game. +pub struct TronCycles { + state: TronState, + player_ids: Vec, + pending_inputs: HashMap, + paused: bool, + game_config: TronConfig, +} + +impl TronCycles { + pub fn new() -> Self { + Self::with_config(TronConfig::load()) + } + + pub fn with_config(config: TronConfig) -> Self { + Self { + state: TronState { + players: HashMap::new(), + wall_segments: Vec::new(), + round_timer: 0.0, + round_complete: false, + round_number: 1, + scores: HashMap::new(), + win_zone: WinZone::default(), + alive_count: 0, + arena_width: config.arena_width, + arena_depth: config.arena_depth, + time_since_last_death: 0.0, + winner_id: None, + }, + player_ids: Vec::new(), + pending_inputs: HashMap::new(), + paused: false, + game_config: config, + } + } + + pub fn state(&self) -> &TronState { + &self.state + } + + pub fn config(&self) -> &TronConfig { + &self.game_config + } + + /// Kill a cycle and record who killed it. + fn kill_cycle(&mut self, player_id: PlayerId, killer_id: Option, is_suicide: bool) { + if let Some(cycle) = self.state.players.get_mut(&player_id) { + if !cycle.alive { + return; + } + cycle.alive = false; + cycle.died = true; + cycle.is_suicide = is_suicide; + self.state.alive_count = self.state.alive_count.saturating_sub(1); + self.state.time_since_last_death = 0.0; + + // Credit the kill to the wall owner + if let Some(kid) = killer_id + && let Some(killer_cycle) = self.state.players.get_mut(&kid) + { + killer_cycle.kills += 1; + } + } + + // Finalize the dead cycle's active wall segment + for wall in &mut self.state.wall_segments { + if wall.owner_id == player_id && wall.is_active { + wall.is_active = false; + } + } + } + + /// Start a new wall segment at the turn point, extending to the cycle's current position. + fn start_new_segment_at( + &mut self, + player_id: PlayerId, + turn_x: f32, + turn_z: f32, + current_x: f32, + current_z: f32, + ) { + // Close the current active segment at the turn point + for wall in &mut self.state.wall_segments { + if wall.owner_id == player_id && wall.is_active { + wall.x2 = turn_x; + wall.z2 = turn_z; + wall.is_active = false; + } + } + + // Start a new active segment from turn point to current position + self.state.wall_segments.push(WallSegment { + x1: turn_x, + z1: turn_z, + x2: current_x, + z2: current_z, + owner_id: player_id, + is_active: true, + }); + } +} + +impl Default for TronCycles { + fn default() -> Self { + Self::with_config(TronConfig::default()) + } +} + +impl BreakpointGame for TronCycles { + fn metadata(&self) -> GameMetadata { + GameMetadata { + name: "Tron Light Cycles".to_string(), + description: "Drive fast, leave walls, don't crash! Grind walls for speed boosts." + .to_string(), + min_players: 2, + max_players: 8, + estimated_round_duration: Duration::from_secs(120), + } + } + + fn tick_rate(&self) -> f32 { + 20.0 + } + + fn round_count_hint(&self) -> u8 { + self.game_config.round_count + } + + fn init(&mut self, players: &[Player], _config: &GameConfig) { + let active_players: Vec<&Player> = players.iter().filter(|p| !p.is_spectator).collect(); + + let arena = arena::create_arena( + self.game_config.arena_width, + self.game_config.arena_depth, + active_players.len(), + ); + + self.state = TronState { + players: HashMap::new(), + wall_segments: Vec::new(), + round_timer: 0.0, + round_complete: false, + round_number: 1, + scores: HashMap::new(), + win_zone: WinZone::default(), + alive_count: active_players.len() as u32, + arena_width: arena.width, + arena_depth: arena.depth, + time_since_last_death: 0.0, + winner_id: None, + }; + self.player_ids.clear(); + self.pending_inputs.clear(); + self.paused = false; + + for (i, player) in active_players.iter().enumerate() { + self.player_ids.push(player.id); + let spawn = &arena.spawn_points[i % arena.spawn_points.len()]; + + let cycle = CycleState { + x: spawn.x, + z: spawn.z, + direction: spawn.direction, + speed: self.game_config.base_speed, + rubber: self.game_config.rubber_max, + brake_fuel: self.game_config.brake_fuel_max, + alive: true, + trail_start_index: self.state.wall_segments.len(), + turn_cooldown: 0.0, + kills: 0, + died: false, + is_suicide: false, + }; + + // Start the initial wall segment for this cycle + self.state.wall_segments.push(WallSegment { + x1: spawn.x, + z1: spawn.z, + x2: spawn.x, + z2: spawn.z, + owner_id: player.id, + is_active: true, + }); + + self.state.players.insert(player.id, cycle); + self.state.scores.insert(player.id, 0); + } + } + + fn update(&mut self, dt: f32, _inputs: &PlayerInputs) -> Vec { + if self.paused || self.state.round_complete { + return Vec::new(); + } + + self.state.round_timer += dt; + self.state.time_since_last_death += dt; + let mut events = Vec::new(); + + // Process each cycle + let player_ids: Vec = self.player_ids.clone(); + for &pid in &player_ids { + let input = self.pending_inputs.remove(&pid).unwrap_or_default(); + + // Save pre-movement position as the potential turn point + let turn_point = self + .state + .players + .get(&pid) + .map(|c| (c.x, c.z, c.direction)); + + // Update cycle physics (applies turn + movement) + physics::update_cycle( + match self.state.players.get_mut(&pid) { + Some(c) => c, + None => continue, + }, + pid, + &input, + &self.state.wall_segments, + self.state.arena_width, + self.state.arena_depth, + dt, + &self.game_config, + ); + + let cycle = match self.state.players.get(&pid) { + Some(c) => c, + None => continue, + }; + + if !cycle.alive { + continue; + } + + // If direction changed, split segment at the PRE-movement turn point + let direction_changed = turn_point + .map(|(_, _, old_dir)| old_dir != cycle.direction) + .unwrap_or(false); + + if direction_changed { + let (tx, tz, _) = turn_point.unwrap(); + self.start_new_segment_at(pid, tx, tz, cycle.x, cycle.z); + } else { + // Update the active segment endpoint + let cx = cycle.x; + let cz = cycle.z; + for wall in &mut self.state.wall_segments { + if wall.owner_id == pid && wall.is_active { + wall.x2 = cx; + wall.z2 = cz; + } + } + } + } + + // Collision detection (separate pass to avoid borrow issues) + let mut kills: Vec<(PlayerId, Option, bool)> = Vec::new(); + + for &pid in &player_ids { + let cycle = match self.state.players.get(&pid) { + Some(c) if c.alive => c, + _ => continue, + }; + + // Check arena boundary + if collision::check_arena_boundary( + cycle, + self.state.arena_width, + self.state.arena_depth, + ) { + kills.push((pid, None, true)); + continue; + } + + // Check wall collisions + let result = collision::check_wall_collision( + cycle, + pid, + &self.state.wall_segments, + &self.game_config, + ); + if !result.alive { + kills.push((pid, result.killer_id, result.is_suicide)); + } + } + + // Apply kills + for (pid, killer_id, is_suicide) in kills { + self.kill_cycle(pid, killer_id, is_suicide); + } + + // Win zone logic + if !self.state.win_zone.active + && win_zone::should_spawn_win_zone( + self.state.round_timer, + self.state.time_since_last_death, + &self.game_config, + ) + { + self.state + .win_zone + .spawn(self.state.arena_width, self.state.arena_depth); + } + + if self.state.win_zone.active { + self.state.win_zone.update(dt, &self.game_config); + + // Check if any alive player entered the win zone + for &pid in &player_ids { + let cycle = match self.state.players.get(&pid) { + Some(c) if c.alive => c, + _ => continue, + }; + if self.state.win_zone.contains(cycle.x, cycle.z) { + // This player wins the round + self.state.winner_id = Some(pid); + self.state.round_complete = true; + events.push(GameEvent::RoundComplete); + return events; + } + } + } + + // Check round completion: last player alive wins + if self.state.alive_count <= 1 && self.player_ids.len() >= 2 { + self.state.round_complete = true; + // Find the winner + for &pid in &player_ids { + if let Some(cycle) = self.state.players.get(&pid) + && cycle.alive + { + self.state.winner_id = Some(pid); + break; + } + } + events.push(GameEvent::RoundComplete); + } + + events + } + + breakpoint_game_boilerplate!(state_type: TronState); + + fn apply_input(&mut self, player_id: PlayerId, input: &[u8]) { + match rmp_serde::from_slice::(input) { + Err(e) => { + tracing::debug!(player_id, error = %e, "Dropped malformed tron input"); + }, + Ok(ti) => { + // Accumulate transient turn flags across frames + if let Some(existing) = self.pending_inputs.get_mut(&player_id) { + // Preserve turn if a turn was requested + if ti.turn != TurnDirection::None { + existing.turn = ti.turn; + } + // Preserve brake (OR logic — once pressed, keep until tick) + if ti.brake { + existing.brake = true; + } + } else { + self.pending_inputs.insert(player_id, ti); + } + }, + } + } + + fn player_joined(&mut self, player: &Player) { + if player.is_spectator || self.player_ids.contains(&player.id) { + return; + } + // Late joiners start as dead spectators for this round + self.player_ids.push(player.id); + let cycle = CycleState { + x: self.state.arena_width / 2.0, + z: self.state.arena_depth / 2.0, + direction: Direction::East, + speed: 0.0, + rubber: 0.0, + brake_fuel: 0.0, + alive: false, + trail_start_index: self.state.wall_segments.len(), + turn_cooldown: 0.0, + kills: 0, + died: true, + is_suicide: false, + }; + self.state.players.insert(player.id, cycle); + self.state.scores.insert(player.id, 0); + } + + fn player_left(&mut self, player_id: PlayerId) { + self.player_ids.retain(|&id| id != player_id); + if let Some(cycle) = self.state.players.get(&player_id) + && cycle.alive + { + self.state.alive_count = self.state.alive_count.saturating_sub(1); + } + self.state.players.remove(&player_id); + self.state.scores.remove(&player_id); + self.pending_inputs.remove(&player_id); + + // Finalize any active wall segments for this player + for wall in &mut self.state.wall_segments { + if wall.owner_id == player_id && wall.is_active { + wall.is_active = false; + } + } + } + + fn round_results(&self) -> Vec { + self.player_ids + .iter() + .map(|&pid| { + let cycle = self.state.players.get(&pid); + let survived = cycle.is_some_and(|c| c.alive); + let died = cycle.is_some_and(|c| c.died); + let is_suicide = cycle.is_some_and(|c| c.is_suicide); + let kills = cycle.map_or(0, |c| c.kills); + + PlayerScore { + player_id: pid, + score: scoring::calculate_score(survived, kills, died, is_suicide), + } + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use breakpoint_core::test_helpers::{default_config, make_players}; + + #[test] + fn init_creates_player_states() { + let mut game = TronCycles::new(); + let players = make_players(4); + game.init(&players, &default_config(120)); + assert_eq!(game.state.players.len(), 4); + assert_eq!(game.state.alive_count, 4); + } + + #[test] + fn state_roundtrip() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + let data = game.serialize_state(); + let mut game2 = TronCycles::new(); + game2.init(&players, &default_config(120)); + game2.apply_state(&data); + + assert_eq!(game.state.players.len(), game2.state.players.len()); + } + + #[test] + fn input_roundtrip() { + let mut game = TronCycles::new(); + let players = make_players(1); + game.init(&players, &default_config(120)); + + let input = TronInput { + turn: TurnDirection::Left, + brake: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + assert!(game.pending_inputs.contains_key(&1)); + } + + #[test] + fn tick_rate_is_20() { + let game = TronCycles::new(); + assert_eq!(game.tick_rate(), 20.0); + } + + #[test] + fn cycles_move_forward_on_update() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + game.update(0.05, &inputs); + + // Verify the round timer advanced (cycles move in their spawn direction) + assert!(game.state.round_timer > 0.0, "Round timer should advance"); + } + + #[test] + fn wall_segments_created_on_init() { + let mut game = TronCycles::new(); + let players = make_players(3); + game.init(&players, &default_config(120)); + + // Each player should have one initial wall segment + assert_eq!(game.state.wall_segments.len(), 3); + for wall in &game.state.wall_segments { + assert!(wall.is_active, "Initial segments should be active"); + } + } + + #[test] + fn turn_creates_new_wall_segment() { + let mut game = TronCycles::new(); + let players = make_players(1); + game.init(&players, &default_config(120)); + + // Move forward a bit first + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + for _ in 0..10 { + game.update(0.05, &inputs); + } + + let segments_before = game.state.wall_segments.len(); + + // Apply a turn + let input = TronInput { + turn: TurnDirection::Left, + brake: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + game.update(0.05, &inputs); + + assert!( + game.state.wall_segments.len() > segments_before, + "Turn should create a new wall segment" + ); + } + + #[test] + fn arena_boundary_kills_cycle() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + // Place a cycle right at the boundary + game.state.players.get_mut(&1).unwrap().x = 0.05; + game.state.players.get_mut(&1).unwrap().z = 250.0; + game.state.players.get_mut(&1).unwrap().direction = Direction::West; + + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + game.update(0.05, &inputs); + + assert!( + !game.state.players[&1].alive, + "Cycle at arena boundary should be killed" + ); + } + + #[test] + fn last_player_wins_round() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + // Kill player 1 + game.kill_cycle(1, None, true); + + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + let events = game.update(0.05, &inputs); + + assert!(game.state.round_complete, "Round should be complete"); + assert_eq!( + game.state.winner_id, + Some(2), + "Player 2 should be the winner" + ); + assert!(events.iter().any(|e| matches!(e, GameEvent::RoundComplete))); + } + + #[test] + fn scoring_correct() { + let mut game = TronCycles::new(); + let players = make_players(3); + game.init(&players, &default_config(120)); + + // Player 2 hits player 3's wall → player 3 gets a kill, player 2 dies + game.kill_cycle(2, Some(3), false); + // Player 1 hits own wall → suicide + game.kill_cycle(1, None, true); + + let results = game.round_results(); + let p1_score = results.iter().find(|r| r.player_id == 1).unwrap().score; + let p2_score = results.iter().find(|r| r.player_id == 2).unwrap().score; + let p3_score = results.iter().find(|r| r.player_id == 3).unwrap().score; + + // Player 1: died (suicide) = -4 + assert_eq!(p1_score, scoring::SUICIDE_POINTS); + // Player 2: died (not suicide) = -2 + assert_eq!(p2_score, scoring::DEATH_POINTS); + // Player 3: survived (+10) + 1 kill (+3) = 13 + assert_eq!(p3_score, scoring::SURVIVE_POINTS + scoring::KILL_POINTS); + } + + #[test] + fn brake_reduces_speed_during_game() { + let mut game = TronCycles::new(); + let players = make_players(1); + game.init(&players, &default_config(120)); + + let speed_before = game.state.players[&1].speed; + + let input = TronInput { + turn: TurnDirection::None, + brake: true, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + game.apply_input(1, &data); + + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + game.update(0.05, &inputs); + + assert!( + game.state.players[&1].speed < speed_before, + "Speed should decrease while braking" + ); + } + + #[test] + fn player_left_cleanup() { + let mut game = TronCycles::new(); + let players = make_players(3); + game.init(&players, &default_config(120)); + + game.player_left(3); + assert_eq!(game.state.players.len(), 2); + assert!(!game.state.players.contains_key(&3)); + } + + // ================================================================ + // Game Trait Contract Tests + // ================================================================ + + #[test] + fn contract_init_creates_player_state() { + let mut game = TronCycles::new(); + breakpoint_core::test_helpers::contract_init_creates_player_state(&mut game, 4); + } + + #[test] + fn contract_apply_input_changes_state() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + let input = TronInput { + turn: TurnDirection::Left, + brake: false, + }; + let data = rmp_serde::to_vec(&input).unwrap(); + breakpoint_core::test_helpers::contract_apply_input_changes_state(&mut game, &data, 1); + } + + #[test] + fn contract_update_advances_time() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_update_advances_time(&mut game); + } + + #[test] + fn contract_round_eventually_completes() { + let mut game = TronCycles::new(); + let players = make_players(2); + // Use small arena so cycles hit walls quickly + game.game_config.arena_width = 50.0; + game.game_config.arena_depth = 50.0; + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_round_eventually_completes(&mut game, 500); + } + + #[test] + fn contract_state_roundtrip_preserves() { + let mut game = TronCycles::new(); + let players = make_players(1); + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_state_roundtrip_preserves(&mut game); + } + + #[test] + fn contract_pause_stops_updates() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_pause_stops_updates(&mut game); + } + + #[test] + fn contract_player_left_cleanup() { + let mut game = TronCycles::new(); + let players = make_players(3); + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_player_left_cleanup(&mut game, 3, 3); + } + + #[test] + fn contract_round_results_complete() { + let mut game = TronCycles::new(); + let players = make_players(4); + game.init(&players, &default_config(120)); + breakpoint_core::test_helpers::contract_round_results_complete(&game, 4); + } + + // ================================================================ + // Input edge cases + // ================================================================ + + #[test] + fn tron_input_encode_decode_roundtrip() { + let input = TronInput { + turn: TurnDirection::Right, + brake: true, + }; + let encoded = rmp_serde::to_vec(&input).unwrap(); + let decoded: TronInput = rmp_serde::from_slice(&encoded).unwrap(); + assert_eq!(decoded.turn, input.turn); + assert_eq!(decoded.brake, input.brake); + } + + #[test] + fn tron_apply_input_garbage_no_panic() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + let garbage: Vec = vec![0xFF, 0xFE, 0x00, 0x01, 0xAB, 0xCD]; + game.apply_input(1, &garbage); + + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + game.update(0.05, &inputs); + // Should not panic + } + + #[test] + fn tron_apply_state_truncated_no_panic() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + let state = game.serialize_state(); + let truncated = &state[..state.len() / 2]; + game.apply_state(truncated); + + // Game should still be functional + assert_eq!(game.state.players.len(), 2); + } + + #[test] + fn tron_turn_input_not_lost_across_overwrites() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + // Frame N: turn left + let input1 = TronInput { + turn: TurnDirection::Left, + brake: false, + }; + let data1 = rmp_serde::to_vec(&input1).unwrap(); + game.apply_input(1, &data1); + + // Frame N+1: no turn (would overwrite in naive impl) + let input2 = TronInput { + turn: TurnDirection::None, + brake: false, + }; + let data2 = rmp_serde::to_vec(&input2).unwrap(); + game.apply_input(1, &data2); + + // Turn should be preserved + assert_eq!( + game.pending_inputs.get(&1).unwrap().turn, + TurnDirection::Left, + "Turn flag must be preserved across input overwrites" + ); + } + + #[test] + fn tron_double_pause_single_resume() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + game.pause(); + game.pause(); + game.resume(); + + let timer_before = game.state.round_timer; + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + game.update(0.05, &inputs); + + assert!( + game.state.round_timer > timer_before, + "Timer should advance after resume" + ); + } + + #[test] + fn tron_update_after_round_complete_is_noop() { + let mut game = TronCycles::new(); + let players = make_players(2); + game.init(&players, &default_config(120)); + + // Force round complete + game.state.round_complete = true; + + let timer = game.state.round_timer; + let inputs = PlayerInputs { + inputs: HashMap::new(), + }; + let events = game.update(0.05, &inputs); + + assert!( + (game.state.round_timer - timer).abs() < 0.001, + "Timer should not advance after round complete" + ); + assert!(events.is_empty(), "No events after round complete"); + } +} diff --git a/crates/games/breakpoint-tron/src/physics.rs b/crates/games/breakpoint-tron/src/physics.rs new file mode 100644 index 0000000..d963cc2 --- /dev/null +++ b/crates/games/breakpoint-tron/src/physics.rs @@ -0,0 +1,260 @@ +use breakpoint_core::game_trait::PlayerId; + +use super::{CycleState, Direction, TronInput, TurnDirection, WallSegment}; +use crate::collision::nearest_wall_distance; +use crate::config::TronConfig; + +/// Apply a turn to the cycle (90 degrees left or right). +pub fn apply_turn(cycle: &mut CycleState, turn: TurnDirection, config: &TronConfig) { + if cycle.turn_cooldown > 0.0 || turn == TurnDirection::None { + return; + } + + cycle.direction = match (cycle.direction, turn) { + (Direction::North, TurnDirection::Left) => Direction::West, + (Direction::North, TurnDirection::Right) => Direction::East, + (Direction::South, TurnDirection::Left) => Direction::East, + (Direction::South, TurnDirection::Right) => Direction::West, + (Direction::East, TurnDirection::Left) => Direction::North, + (Direction::East, TurnDirection::Right) => Direction::South, + (Direction::West, TurnDirection::Left) => Direction::South, + (Direction::West, TurnDirection::Right) => Direction::North, + (_, TurnDirection::None) => return, + }; + + // Speed penalty for turning + cycle.speed *= 1.0 - config.turn_speed_penalty; + cycle.turn_cooldown = config.turn_delay; +} + +/// Apply brake to the cycle. +pub fn apply_brake(cycle: &mut CycleState, dt: f32, config: &TronConfig) { + if cycle.brake_fuel > 0.0 { + cycle.brake_fuel = (cycle.brake_fuel - config.brake_drain_rate * dt).max(0.0); + cycle.speed *= config.brake_speed_mult.powf(dt); + } +} + +/// Regenerate brake fuel when not braking. +pub fn regen_brake(cycle: &mut CycleState, dt: f32, config: &TronConfig) { + cycle.brake_fuel = (cycle.brake_fuel + config.brake_regen_rate * dt).min(config.brake_fuel_max); +} + +/// Compute wall acceleration (grinding) based on proximity to walls. +pub fn wall_acceleration( + cycle: &CycleState, + cycle_owner_id: PlayerId, + walls: &[WallSegment], + arena_width: f32, + arena_depth: f32, + config: &TronConfig, +) -> f32 { + let Some(dist) = nearest_wall_distance( + cycle, + cycle_owner_id, + walls, + arena_width, + arena_depth, + config.grind_distance, + ) else { + return 0.0; + }; + + // Closer = more acceleration, inversely proportional + // At collision_distance: max boost. At grind_distance: no boost. + let range = config.grind_distance - config.collision_distance; + if range <= 0.0 { + return 0.0; + } + + let normalized = ((dist - config.collision_distance) / range).clamp(0.0, 1.0); + let boost_factor = 1.0 - normalized; // 1.0 at closest, 0.0 at threshold + + let max_accel = config.base_speed * (config.grind_max_multiplier - 1.0); + boost_factor * max_accel +} + +/// Update cycle position based on its direction and speed. +/// Returns the new wall segment endpoint if the cycle moved. +#[allow(clippy::too_many_arguments)] +pub fn update_cycle( + cycle: &mut CycleState, + cycle_owner_id: PlayerId, + input: &TronInput, + walls: &[WallSegment], + arena_width: f32, + arena_depth: f32, + dt: f32, + config: &TronConfig, +) -> Option<(f32, f32)> { + if !cycle.alive { + return None; + } + + // Turn cooldown + cycle.turn_cooldown = (cycle.turn_cooldown - dt).max(0.0); + + // Apply turn + match input.turn { + TurnDirection::Left => apply_turn(cycle, TurnDirection::Left, config), + TurnDirection::Right => apply_turn(cycle, TurnDirection::Right, config), + TurnDirection::None => {}, + } + + // Braking + if input.brake { + apply_brake(cycle, dt, config); + } else { + regen_brake(cycle, dt, config); + } + + // Wall acceleration (grinding) + let accel = wall_acceleration( + cycle, + cycle_owner_id, + walls, + arena_width, + arena_depth, + config, + ); + cycle.speed += accel * dt; + + // Speed decay toward base speed (skip recovery when braking) + if cycle.speed > config.base_speed { + cycle.speed = (cycle.speed - config.speed_decay_rate * dt).max(config.base_speed); + } else if cycle.speed < config.base_speed && !input.brake { + // Fast recovery if below base speed (but not while braking) + cycle.speed = (cycle.speed + config.speed_decay_rate * 2.0 * dt).min(config.base_speed); + } + + // Clamp speed + cycle.speed = cycle.speed.clamp(config.base_speed * 0.3, config.max_speed); + + // Move + let distance = cycle.speed * dt; + let (dx, dz) = match cycle.direction { + Direction::North => (0.0, -distance), + Direction::South => (0.0, distance), + Direction::East => (distance, 0.0), + Direction::West => (-distance, 0.0), + }; + + let old_x = cycle.x; + let old_z = cycle.z; + cycle.x += dx; + cycle.z += dz; + + // Return the previous position as the start of the current segment + if (old_x - cycle.x).abs() > 0.001 || (old_z - cycle.z).abs() > 0.001 { + Some((old_x, old_z)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn default_cycle() -> CycleState { + CycleState { + x: 250.0, + z: 250.0, + direction: Direction::East, + speed: 20.0, + rubber: 0.5, + brake_fuel: 3.0, + alive: true, + trail_start_index: 0, + turn_cooldown: 0.0, + kills: 0, + died: false, + is_suicide: false, + } + } + + fn no_input() -> TronInput { + TronInput { + turn: TurnDirection::None, + brake: false, + } + } + + #[test] + fn cycle_moves_forward() { + let mut cycle = default_cycle(); + let config = TronConfig::default(); + let input = no_input(); + let x_before = cycle.x; + + update_cycle(&mut cycle, 1, &input, &[], 500.0, 500.0, 0.05, &config); + + assert!(cycle.x > x_before, "Cycle should move east"); + } + + #[test] + fn turn_changes_direction() { + let mut cycle = default_cycle(); + let config = TronConfig::default(); + + apply_turn(&mut cycle, TurnDirection::Left, &config); + assert_eq!(cycle.direction, Direction::North); + + cycle.turn_cooldown = 0.0; + apply_turn(&mut cycle, TurnDirection::Right, &config); + assert_eq!(cycle.direction, Direction::East); + } + + #[test] + fn turn_cooldown_prevents_rapid_turns() { + let mut cycle = default_cycle(); + let config = TronConfig::default(); + + apply_turn(&mut cycle, TurnDirection::Left, &config); + assert_eq!(cycle.direction, Direction::North); + + // Should be blocked by cooldown + apply_turn(&mut cycle, TurnDirection::Left, &config); + assert_eq!( + cycle.direction, + Direction::North, + "Should not turn during cooldown" + ); + } + + #[test] + fn brake_reduces_speed() { + let mut cycle = default_cycle(); + let config = TronConfig::default(); + let speed_before = cycle.speed; + + apply_brake(&mut cycle, 0.05, &config); + assert!(cycle.speed < speed_before, "Braking should reduce speed"); + } + + #[test] + fn brake_depletes_fuel() { + let mut cycle = default_cycle(); + let config = TronConfig::default(); + let fuel_before = cycle.brake_fuel; + + apply_brake(&mut cycle, 1.0, &config); + assert!( + cycle.brake_fuel < fuel_before, + "Braking should consume fuel" + ); + } + + #[test] + fn brake_regen_when_not_braking() { + let mut cycle = default_cycle(); + cycle.brake_fuel = 0.0; + let config = TronConfig::default(); + + regen_brake(&mut cycle, 1.0, &config); + assert!( + cycle.brake_fuel > 0.0, + "Brake fuel should regenerate when not braking" + ); + } +} diff --git a/crates/games/breakpoint-tron/src/scoring.rs b/crates/games/breakpoint-tron/src/scoring.rs new file mode 100644 index 0000000..371bd28 --- /dev/null +++ b/crates/games/breakpoint-tron/src/scoring.rs @@ -0,0 +1,50 @@ +/// Points awarded for surviving the round. +pub const SURVIVE_POINTS: i32 = 10; +/// Points awarded per kill (opponent hits your wall). +pub const KILL_POINTS: i32 = 3; +/// Points deducted for dying. +pub const DEATH_POINTS: i32 = -2; +/// Points deducted for suicide (hitting your own wall). +pub const SUICIDE_POINTS: i32 = -4; + +/// Calculate a player's score for a round. +pub fn calculate_score(survived: bool, kills: u32, died: bool, suicide: bool) -> i32 { + let mut score = 0; + if survived { + score += SURVIVE_POINTS; + } + score += kills as i32 * KILL_POINTS; + if died { + if suicide { + score += SUICIDE_POINTS; + } else { + score += DEATH_POINTS; + } + } + score +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn survivor_with_kills() { + assert_eq!(calculate_score(true, 3, false, false), 10 + 9); + } + + #[test] + fn died_to_enemy() { + assert_eq!(calculate_score(false, 0, true, false), -2); + } + + #[test] + fn suicide_penalty() { + assert_eq!(calculate_score(false, 0, true, true), -4); + } + + #[test] + fn no_events() { + assert_eq!(calculate_score(false, 0, false, false), 0); + } +} diff --git a/crates/games/breakpoint-tron/src/win_zone.rs b/crates/games/breakpoint-tron/src/win_zone.rs new file mode 100644 index 0000000..db0892d --- /dev/null +++ b/crates/games/breakpoint-tron/src/win_zone.rs @@ -0,0 +1,115 @@ +use serde::{Deserialize, Serialize}; + +use crate::config::TronConfig; + +/// Expanding win zone that forces round resolution after timeout. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WinZone { + /// Center X position. + pub x: f32, + /// Center Z position. + pub z: f32, + /// Current radius. + pub radius: f32, + /// Whether the win zone is currently active. + pub active: bool, +} + +impl Default for WinZone { + fn default() -> Self { + Self { + x: 0.0, + z: 0.0, + radius: 0.0, + active: false, + } + } +} + +impl WinZone { + /// Spawn the win zone at a random position within the arena. + pub fn spawn(&mut self, arena_width: f32, arena_depth: f32) { + // Place in center quarter of arena for fairness + let margin = arena_width.min(arena_depth) * 0.25; + self.x = arena_width / 2.0; + self.z = arena_depth / 2.0; + // Add some randomness with simple hash + let hash = ((arena_width as u32) + .wrapping_mul(31) + .wrapping_add(arena_depth as u32)) as f32; + self.x += (hash % margin) - margin / 2.0; + self.z += ((hash * 1.7) % margin) - margin / 2.0; + self.radius = 5.0; + self.active = true; + } + + /// Update the win zone (expand). + pub fn update(&mut self, dt: f32, config: &TronConfig) { + if self.active { + self.radius += config.win_zone_expand_rate * dt; + } + } + + /// Check if a point is inside the win zone. + pub fn contains(&self, x: f32, z: f32) -> bool { + if !self.active { + return false; + } + let dx = x - self.x; + let dz = z - self.z; + dx * dx + dz * dz <= self.radius * self.radius + } +} + +/// Check whether the win zone should appear based on round timer and last death time. +pub fn should_spawn_win_zone( + round_timer: f32, + time_since_last_death: f32, + config: &TronConfig, +) -> bool { + round_timer >= config.win_zone_delay && time_since_last_death >= config.win_zone_death_delay +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn win_zone_spawn_and_contain() { + let mut wz = WinZone::default(); + assert!(!wz.active); + assert!(!wz.contains(250.0, 250.0)); + + wz.spawn(500.0, 500.0); + assert!(wz.active); + assert!(wz.radius > 0.0); + + // Center should be within the zone + assert!(wz.contains(wz.x, wz.z)); + } + + #[test] + fn win_zone_expands() { + let config = TronConfig::default(); + let mut wz = WinZone::default(); + wz.spawn(500.0, 500.0); + let r_before = wz.radius; + + wz.update(1.0, &config); + assert!(wz.radius > r_before, "Win zone should expand"); + } + + #[test] + fn win_zone_timing() { + let config = TronConfig::default(); + + // Too early + assert!(!should_spawn_win_zone(30.0, 40.0, &config)); + + // Round time OK but recent death + assert!(!should_spawn_win_zone(65.0, 10.0, &config)); + + // Both conditions met + assert!(should_spawn_win_zone(65.0, 35.0, &config)); + } +} diff --git a/web/index.html b/web/index.html index 7096e92..8575b41 100644 --- a/web/index.html +++ b/web/index.html @@ -27,6 +27,7 @@

BREAKPOINT

+ diff --git a/web/ui.js b/web/ui.js index 27e6d96..e930f08 100644 --- a/web/ui.js +++ b/web/ui.js @@ -100,6 +100,7 @@ "mini-golf": "Click to aim & shoot | Power = distance from ball", "platform-racer": "WASD / Arrows = Move | Space = Jump | E = Use Power-Up", "laser-tag": "WASD = Move | Mouse = Aim | Click = Fire | E = Power-Up", + "tron": "A/D or Left/Right = Turn | Space = Brake", }; // ── Game name display ─────────────────────────────── @@ -110,6 +111,8 @@ "Platformer": "Platform Racer", "laser-tag": "Laser Tag", "LaserTag": "Laser Tag", + "tron": "Tron", + "Tron": "Tron", }; // ── State update from WASM ──────────────────────────