diff --git a/.gitignore b/.gitignore index f9d09756..6113bb0b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ target/ tmp/ node_modules +.idea .env .envrc .env.local diff --git a/late-core/migrations/090_create_traffic.sql b/late-core/migrations/090_create_traffic.sql new file mode 100644 index 00000000..2d5b9bfc --- /dev/null +++ b/late-core/migrations/090_create_traffic.sql @@ -0,0 +1,21 @@ +-- Traffic keeps one best score per (user, track); the Traffic high score is the +-- sum of a user's per-track bests. Per-track scores are normalized 0..1000. +CREATE TABLE traffic_track_scores ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + track_key TEXT NOT NULL, + score INT NOT NULL DEFAULT 0, + UNIQUE (user_id, track_key) +); + +-- Aggregate row mirroring the other high-score games so leaderboard queries +-- stay uniform. score is always the sum of traffic_track_scores for the user. +CREATE TABLE traffic_high_scores ( + id UUID PRIMARY KEY DEFAULT uuidv7(), + created TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + updated TIMESTAMPTZ NOT NULL DEFAULT current_timestamp, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + score INT NOT NULL DEFAULT 0 +); diff --git a/late-core/src/models/leaderboard.rs b/late-core/src/models/leaderboard.rs index 6d410a6f..d2058f30 100644 --- a/late-core/src/models/leaderboard.rs +++ b/late-core/src/models/leaderboard.rs @@ -74,6 +74,7 @@ pub struct LeaderboardData { pub monthly_tetris_high_scores: Vec, pub monthly_2048_high_scores: Vec, pub monthly_snake_high_scores: Vec, + pub monthly_traffic_high_scores: Vec, } pub async fn fetch_leaderboard_data(client: &Client) -> Result { @@ -88,6 +89,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result monthly_tetris_high_scores, monthly_2048_high_scores, monthly_snake_high_scores, + monthly_traffic_high_scores, ) = tokio::try_join!( fetch_today_champions(client, 10), fetch_today_daily_statuses(client), @@ -99,6 +101,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result fetch_monthly_tetris_high_scores(client, 500), fetch_monthly_2048_high_scores(client, 500), fetch_monthly_snake_high_scores(client, 500), + fetch_monthly_traffic_high_scores(client, 500), )?; Ok(LeaderboardData { @@ -112,6 +115,7 @@ pub async fn fetch_leaderboard_data(client: &Client) -> Result monthly_tetris_high_scores, monthly_2048_high_scores, monthly_snake_high_scores, + monthly_traffic_high_scores, }) } @@ -312,6 +316,34 @@ async fn fetch_high_scores(client: &Client, limit: i64) -> Result Result> { + fetch_monthly_score_board(client, "Traffic", "traffic", "traffic_high_scores", limit).await +} + async fn fetch_monthly_score_board( client: &Client, display_game: &'static str, diff --git a/late-core/src/models/mod.rs b/late-core/src/models/mod.rs index 84e1a803..cc1d498c 100644 --- a/late-core/src/models/mod.rs +++ b/late-core/src/models/mod.rs @@ -55,6 +55,7 @@ pub mod snake; pub mod solitaire; pub mod sudoku; pub mod tetris; +pub mod traffic; pub mod twenty_forty_eight; pub mod ultimate_cooldown; pub mod user; diff --git a/late-core/src/models/traffic.rs b/late-core/src/models/traffic.rs new file mode 100644 index 00000000..df792d79 --- /dev/null +++ b/late-core/src/models/traffic.rs @@ -0,0 +1,108 @@ +use anyhow::Result; +use tokio_postgres::Client; +use uuid::Uuid; + +/// One user's best normalized score (0..1000) for a single track. +pub struct TrackScore { + pub track_key: String, + pub score: i32, +} + +/// A user's aggregate Traffic high score: the sum of their per-track bests. +pub struct HighScore { + pub user_id: Uuid, + pub score: i32, +} + +impl TrackScore { + /// Every per-track best the user has recorded. + pub async fn list_for_user(client: &Client, user_id: Uuid) -> Result> { + let rows = client + .query( + "SELECT track_key, score FROM traffic_track_scores WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(rows + .into_iter() + .map(|row| TrackScore { + track_key: row.get("track_key"), + score: row.get("score"), + }) + .collect()) + } +} + +impl HighScore { + pub async fn find_by_user_id(client: &Client, user_id: Uuid) -> Result> { + let row = client + .query_opt( + "SELECT user_id, score FROM traffic_high_scores WHERE user_id = $1", + &[&user_id], + ) + .await?; + Ok(row.map(|row| Self { + user_id: row.get("user_id"), + score: row.get("score"), + })) + } + + /// Record a track finish: keep the best per-track score, then recompute the + /// aggregate Traffic high score as the sum of the user's per-track bests. + /// Returns the new aggregate total. + /// + /// All three statements run in one transaction so the `traffic_high_scores` + /// aggregate can never persist a total that disagrees with the source-of- + /// truth `traffic_track_scores` sum, even under concurrent finishes for the + /// same user. + pub async fn update_track_score_if_higher( + client: &mut Client, + user_id: Uuid, + track_key: &str, + new_score: i32, + ) -> Result { + let tx = client.transaction().await?; + + tx.execute( + "INSERT INTO traffic_track_scores (user_id, track_key, score) + VALUES ($1, $2, $3) + ON CONFLICT (user_id, track_key) DO UPDATE + SET score = GREATEST(traffic_track_scores.score, $3), + updated = current_timestamp", + &[&user_id, &track_key, &new_score], + ) + .await?; + + let total: i32 = tx + .query_one( + "SELECT COALESCE(SUM(score), 0)::int AS total + FROM traffic_track_scores WHERE user_id = $1", + &[&user_id], + ) + .await? + .get("total"); + + tx.execute( + "INSERT INTO traffic_high_scores (user_id, score) + VALUES ($1, $2) + ON CONFLICT (user_id) DO UPDATE + SET score = $2, updated = current_timestamp", + &[&user_id, &total], + ) + .await?; + + tx.commit().await?; + Ok(total) + } + + pub async fn record_score_event(client: &Client, user_id: Uuid, total: i32) -> Result<()> { + client + .execute( + "INSERT INTO game_score_events (user_id, game, score) + VALUES ($1, 'traffic', $2)", + &[&user_id, &total], + ) + .await?; + Ok(()) + } +} diff --git a/late-ssh/src/app/activity/event.rs b/late-ssh/src/app/activity/event.rs index a92065ea..abc64c5c 100644 --- a/late-ssh/src/app/activity/event.rs +++ b/late-ssh/src/app/activity/event.rs @@ -74,6 +74,7 @@ pub enum ActivityGame { TwentyFortyEight, Tron, Snake, + Traffic, } impl ActivityGame { @@ -97,6 +98,7 @@ impl ActivityGame { Self::TwentyFortyEight => "2048", Self::Tron => "tron", Self::Snake => "snake", + Self::Traffic => "traffic", } } @@ -120,6 +122,7 @@ impl ActivityGame { Self::TwentyFortyEight => "2048", Self::Tron => "Tron", Self::Snake => "Snake", + Self::Traffic => "Traffic", } } } @@ -189,6 +192,7 @@ impl ActivityEvent { ActivityGame::TwentyFortyEight => "won 2048", ActivityGame::Tron => "won Tron round", ActivityGame::Snake => "won Snake", + ActivityGame::Traffic => "finished a Traffic track", }; let action = match detail.as_deref() { Some(detail) if !detail.is_empty() => format!("{base_action} ({detail})"), diff --git a/late-ssh/src/app/arcade/CONTEXT.md b/late-ssh/src/app/arcade/CONTEXT.md index 5cca1be8..eb99257b 100644 --- a/late-ssh/src/app/arcade/CONTEXT.md +++ b/late-ssh/src/app/arcade/CONTEXT.md @@ -26,6 +26,7 @@ Keep `mod.rs` declaration-only. Do not add `pub use` re-export layers. - `input.rs` routes The Arcade lobby and selected active game input. - `ui.rs` renders the lobby and exposes Arcade-only bottom-bar/status helpers. - `twenty_forty_eight/`, `tetris/`, and `snake/` are high-score games. +- `traffic/` is a multi-track high-score game. Each track finish is graded to a normalized `0..=1000` score (`Track::grade_time`, from the track's theoretical fastest/slowest completion time, so every track yields a comparable range regardless of its distance/speed definition); crashing before the finish scores nothing. The user's Traffic high score is the **sum** of their per-track bests. Persistence keeps one best per `(user, track_key)` in `traffic_track_scores` plus a mirrored aggregate row in `traffic_high_scores` (`= SUM(track scores)`) so leaderboard queries stay uniform with the other high-score games. `track_key` is the `Track::name`. - `rubiks_cube/` is a daily deterministic puzzle game with a real cube state, face turns, a three-face angled render, and a compact net. It records one daily win per user/date, publishes Activity for the once-per-day base chip payout and Hub quest progress, and counts toward Arcade Wins. - `nes_cabinet/` is a Potatis-backed local emulator cabinet for bundled legal/homebrew ROMs: Squirrel Domino, Thwaite, DABG, Falling, Brick Breaker, Escape from Pong, RHDE, Concentration Room, Zap Ruder, and 2048. - `sudoku/`, `nonogram/`, `minesweeper/`, `solitaire/`, `le_word/`, and `rubiks_cube/` are daily puzzle games. Le Word has a single global daily word rather than personal runs. Rubik's Cube has one shared daily scramble and no personal mode. @@ -61,6 +62,7 @@ Per-game directories generally follow: | Category | Games | Persistence | Leaderboard | | --- | --- | --- | --- | | High-score | 2048, Lateris, Snake | One current run plus best score plus final score events | Monthly and all-time high scores in Hub | +| High-score (multi-track) | Traffic | One best per track (`traffic_track_scores`) plus aggregate sum (`traffic_high_scores`) plus final score events | Monthly and all-time Traffic high scores in Hub | | Daily puzzles | Sudoku, Nonograms, Minesweeper, Solitaire, Le Word, Rubik's Cube | One daily and one personal slot per user/difficulty or pack, except Le Word's global daily answer and Rubik's shared daily scramble | Daily completion status / Arcade Wins in Hub, plus Hub Quests via Activity | | Emulator cabinet | NES Cabinet | Runtime only, bundled ROMs only | None | | Economy support | Chips | `user_chips` plus `chip_ledger` | Monthly chip earners in Hub | diff --git a/late-ssh/src/app/arcade/input.rs b/late-ssh/src/app/arcade/input.rs index 74daca8b..57aa92bc 100644 --- a/late-ssh/src/app/arcade/input.rs +++ b/late-ssh/src/app/arcade/input.rs @@ -9,13 +9,14 @@ use crate::app::state::{ GAME_SELECTION_NES_ESCAPE_FROM_PONG, GAME_SELECTION_NES_FALLING, GAME_SELECTION_NES_RHDE, GAME_SELECTION_NES_SQUIRREL_DOMINO, GAME_SELECTION_NES_THWAITE, GAME_SELECTION_NES_ZAP_RUDER, GAME_SELECTION_NONOGRAMS, GAME_SELECTION_RUBIKS_CUBE, GAME_SELECTION_SNAKE, - GAME_SELECTION_SOLITAIRE, GAME_SELECTION_SUDOKU, GAME_SELECTION_TETRIS, + GAME_SELECTION_SOLITAIRE, GAME_SELECTION_SUDOKU, GAME_SELECTION_TETRIS, GAME_SELECTION_TRAFFIC, }; -const LOBBY_GAME_ORDER: [usize; 19] = [ +const LOBBY_GAME_ORDER: [usize; 20] = [ GAME_SELECTION_2048, GAME_SELECTION_TETRIS, GAME_SELECTION_SNAKE, + GAME_SELECTION_TRAFFIC, GAME_SELECTION_LE_WORD, GAME_SELECTION_RUBIKS_CUBE, GAME_SELECTION_SUDOKU, @@ -107,6 +108,12 @@ pub fn handle_key(app: &mut App, byte: u8) -> bool { return true; } return super::snake::input::handle_key(&mut app.snake_state, byte); + } else if app.game_selection == GAME_SELECTION_TRAFFIC { + if byte == 0x1B || byte == b'q' || byte == b'Q' { + app.is_playing_game = false; + return true; + } + return super::traffic::input::handle_key(&mut app.traffic_state, byte); } else if app.game_selection == GAME_SELECTION_RUBIKS_CUBE { if byte == 0x1B || byte == b'q' || byte == b'Q' { app.is_playing_game = false; @@ -177,6 +184,7 @@ pub fn handle_key(app: &mut App, byte: u8) -> bool { if app.game_selection == GAME_SELECTION_2048 || app.game_selection == GAME_SELECTION_TETRIS || app.game_selection == GAME_SELECTION_SNAKE + || app.game_selection == GAME_SELECTION_TRAFFIC || app.game_selection == GAME_SELECTION_RUBIKS_CUBE || app.game_selection == GAME_SELECTION_LE_WORD || is_nes_selection(app.game_selection) @@ -219,6 +227,8 @@ pub fn handle_arrow(app: &mut App, key: u8) -> bool { return super::tetris::input::handle_arrow(&mut app.tetris_state, key); } else if app.game_selection == GAME_SELECTION_SNAKE { return super::snake::input::handle_arrow(&mut app.snake_state, key); + } else if app.game_selection == GAME_SELECTION_TRAFFIC { + return super::traffic::input::handle_arrow(&mut app.traffic_state, key); } else if app.game_selection == GAME_SELECTION_RUBIKS_CUBE { app.rubiks_cube_state.ensure_current_daily(); return super::rubiks_cube::input::handle_arrow(&mut app.rubiks_cube_state, key); @@ -333,6 +343,10 @@ mod tests { ); assert_eq!( next_lobby_selection(GAME_SELECTION_SNAKE), + GAME_SELECTION_TRAFFIC + ); + assert_eq!( + next_lobby_selection(GAME_SELECTION_TRAFFIC), GAME_SELECTION_LE_WORD ); assert_eq!( @@ -359,6 +373,10 @@ mod tests { next_lobby_selection(GAME_SELECTION_NES_FALLING), GAME_SELECTION_NES_BRICK_BREAKER ); + assert_eq!( + next_lobby_selection(GAME_SELECTION_NES_FALLING), + GAME_SELECTION_NES_BRICK_BREAKER + ); assert_eq!( next_lobby_selection(GAME_SELECTION_NES_BRICK_BREAKER), GAME_SELECTION_NES_ESCAPE_FROM_PONG diff --git a/late-ssh/src/app/arcade/mod.rs b/late-ssh/src/app/arcade/mod.rs index 9990bac6..b5cd5cec 100644 --- a/late-ssh/src/app/arcade/mod.rs +++ b/late-ssh/src/app/arcade/mod.rs @@ -8,5 +8,6 @@ pub mod snake; pub mod solitaire; pub mod sudoku; pub mod tetris; +pub mod traffic; pub mod twenty_forty_eight; pub mod ui; diff --git a/late-ssh/src/app/arcade/traffic/input.rs b/late-ssh/src/app/arcade/traffic/input.rs new file mode 100644 index 00000000..ae20467c --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/input.rs @@ -0,0 +1,148 @@ +//! Key input dispatch for the Traffic game. +//! +//! Two surfaces share one state: the picker (track selection) and the +//! racing view. The dispatcher routes to the right handler based on +//! `state.screen`. + +use super::state::{PlayerInput, State, TrafficScreen}; + +// ─── Picker ───────────────────────────────────────────────────────────────── + +fn handle_picker_key(state: &mut State, byte: u8) -> bool { + match byte { + b'j' | b'J' => { + state.picker_move(1); + true + } + b'k' | b'K' => { + state.picker_move(-1); + true + } + b'\r' | b'\n' | b' ' => { + state.start_selected_track(); + true + } + _ => false, + } +} + +fn handle_picker_arrow(state: &mut State, key: u8) -> bool { + match key { + b'A' => { + state.picker_move(-1); + true + } + b'B' => { + state.picker_move(1); + true + } + _ => false, + } +} + +// ─── Racing ───────────────────────────────────────────────────────────────── + +fn handle_race_key(state: &mut State, byte: u8) -> bool { + if state.is_crashed() { + state.resume_from_crash(); + return true; + } + match byte { + b'w' | b'W' => { + state.set_input(PlayerInput::Accelerate); + true + } + b's' | b'S' => { + state.set_input(PlayerInput::Brake); + true + } + b'a' | b'A' => { + state.move_left(); + true + } + b'd' | b'D' => { + state.move_right(); + true + } + b' ' => { + state.set_input(PlayerInput::Handbrake); + true + } + b'p' | b'P' => { + state.toggle_pause(); + true + } + b'r' | b'R' => { + state.restart_current(); + true + } + b't' | b'T' => { + state.return_to_picker(); + true + } + _ => false, + } +} + +fn handle_race_arrow(state: &mut State, key: u8) -> bool { + if state.is_crashed() { + state.resume_from_crash(); + return true; + } + match key { + b'A' => { + state.set_input(PlayerInput::Accelerate); + true + } + b'B' => { + state.set_input(PlayerInput::Brake); + true + } + b'C' => { + state.move_right(); + true + } + b'D' => { + state.move_left(); + true + } + _ => false, + } +} + +// ─── Dispatchers ─────────────────────────────────────────────────────────── + +pub fn handle_key(state: &mut State, byte: u8) -> bool { + match state.screen { + TrafficScreen::Picker => handle_picker_key(state, byte), + TrafficScreen::Racing => handle_race_key(state, byte), + } +} + +pub fn handle_arrow(state: &mut State, key: u8) -> bool { + match state.screen { + TrafficScreen::Picker => handle_picker_arrow(state, key), + TrafficScreen::Racing => handle_race_arrow(state, key), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::arcade::traffic::tracks::DEFAULT_TRACK; + + #[test] + fn picker_enter_starts_a_track() { + let mut s = State::new(); + handle_key(&mut s, b'\r'); + assert_eq!(s.screen, TrafficScreen::Racing); + } + + #[test] + fn race_w_sets_accelerate() { + let mut s = State::new(); + s.start_track(DEFAULT_TRACK); + handle_key(&mut s, b'w'); + assert!(matches!(s.input, PlayerInput::Accelerate)); + } +} diff --git a/late-ssh/src/app/arcade/traffic/mod.rs b/late-ssh/src/app/arcade/traffic/mod.rs new file mode 100644 index 00000000..7b1681a2 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/mod.rs @@ -0,0 +1,7 @@ +pub mod input; +pub mod state; +pub mod svc; +pub mod theme; +pub mod track; +pub mod tracks; +pub mod ui; diff --git a/late-ssh/src/app/arcade/traffic/state.rs b/late-ssh/src/app/arcade/traffic/state.rs new file mode 100644 index 00000000..0eaf19b9 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/state.rs @@ -0,0 +1,1109 @@ +//! Traffic game state and physics. +//! +//! Geometry, lane configuration, and speed ranges are no longer hard-coded — +//! they're read live from the active [`Track`] and current [`Stage`] (see +//! `track.rs` and `tracks/`). What remains in [`Config`] is only the visual +//! frame (terminal rows, lane width in chars, FPS) and the global player +//! input feel (accel/decel/lane-transition speed). + +use std::collections::{HashMap, VecDeque}; +use std::time::{Duration, Instant}; + +use rand::{Rng, random}; + +use super::track::{ObstacleEffect, ObstacleStyle, Stage, Track}; +use super::tracks::{ALL_TRACKS, DEFAULT_TRACK}; + +// ─── Static visual-frame configuration ────────────────────────────────────── + +pub struct Config; + +impl Config { + /// Total terminal rows allocated to the game viewport. + pub const VISIBLE_ROWS: u16 = 50; + /// Width of a single lane in character columns. + pub const LANE_WIDTH: u16 = 5; + /// Height of any car (player and AI) in terminal rows. Drives collision + /// boxes, minimap scale, and player rear-position calculations. + pub const CAR_HEIGHT_ROWS: u16 = 3; + /// Rows between the bottom of the player car and the bottom of the viewport. + pub const PLAYER_BOTTOM_MARGIN: u16 = 4; + /// Screen row of the top edge of the player car (0 = top of viewport). + /// All road-to-screen geometry is anchored here. + pub const PLAYER_TOP_ROW: u16 = + Self::VISIBLE_ROWS - Self::CAR_HEIGHT_ROWS - Self::PLAYER_BOTTOM_MARGIN; + /// World-space scale: metres represented by one terminal row. + /// Every metre ↔ row conversion multiplies or divides by this. + pub const METERS_PER_ROW: f32 = 3.0; + /// Metres of road visible ahead of the player (top of car to top of screen). + pub const VISIBLE_AHEAD_M: f32 = Self::PLAYER_TOP_ROW as f32 * Self::METERS_PER_ROW; + /// Metres covered by the minimap. Also used as the obstacle seeding horizon + /// so the minimap always shows what has already been populated. + pub const MINIMAP_RANGE_M: f32 = 2.0 * Self::VISIBLE_ROWS as f32 * Self::METERS_PER_ROW; + /// Minimum front-to-back gap enforced between any two AI cars in the same + /// lane during placement. + pub const AI_MIN_SEPARATION_M: f32 = 32.0; + /// Distance behind the player after which AI cars and obstacles are removed. + pub const AI_DESPAWN_BEHIND_M: f32 = 200.0; + /// Follow distance in rows: converted to metres each tick to determine when + /// an AI car slows to match the speed of the one directly ahead. + pub const AI_FOLLOW_GAP_ROWS: u16 = 5; + /// Outer edge of the managed traffic zone. New cars are seeded anywhere + /// between `MINIMAP_RANGE_M` and here so they scroll naturally into view. + pub const SPAWN_AHEAD_M: f32 = Self::MINIMAP_RANGE_M * 1.5; + /// Safe gap left clear in front of the player during initial fill so the + /// road immediately ahead is obstacle-free on race start. + pub const INITIAL_SKIP_M: f32 = Self::AI_MIN_SEPARATION_M * 2.5; + /// Negative offset applied to `player_pos_m` at race start so the player + /// spawns just before the first stage separator and it scrolls in naturally. + pub const PRE_STAGE_M: f32 = Self::VISIBLE_AHEAD_M + Self::METERS_PER_ROW * 2.0; + /// Half-width of the obstacle exclusion zone around every stage boundary. + /// No obstacles are placed within this distance of a separator in either direction. + pub const STAGE_CLEAR_M: f32 = 12.0; + + /// Player speed at race start and after every restart. + pub const PLAYER_START_SPEED_KMH: f32 = 50.0; + /// Speed gained per second while holding accelerate. + pub const ACCEL_KMH_PER_S: f32 = 88.0; + /// Speed lost per second while braking; doubled when using the handbrake. + pub const DECEL_KMH_PER_S: f32 = 128.0; + /// Rate at which speed eases back into the new lane's bounds after a lane + /// change, preventing an instant hard clamp. + pub const SPEED_CLAMP_PER_S: f32 = 80.0; + /// Fixed physics timestep (15 Hz). All per-frame increments multiply by this. + pub const TICK_DT: f32 = 1.0 / 15.0; + /// Milliseconds a held-key input stays active before auto-releasing. + pub const INPUT_HOLD_MS: u64 = 150; + /// Duration of the on/off recovery flash after dismissing a crash popup. Also provides temporary immunity. + pub const CRASH_FLASH_MS: u64 = 1500; + /// Visual lane-change speed in display-lane-units per second. Controls + /// how fast the player car glides to the target lane on screen. + pub const LANE_TRANSITION_PER_S: f32 = 7.0; + + /// Maximum entries kept in the right-panel obstacle-effect log. + pub const RECENT_EFFECTS_CAPACITY: usize = 5; + + /// Minimum terminal width (columns) required to render the game. + pub const MIN_TERMINAL_WIDTH_FLOOR: u16 = 70; + /// Minimum terminal height (rows) required to render the game. + pub const MIN_TERMINAL_HEIGHT: u16 = Self::VISIBLE_ROWS + 3; +} + +// ─── Top-level state machine ───────────────────────────────────────────────── + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TrafficScreen { + Picker, + Racing, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum TrafficDir { + Same, + Oncoming, +} + +#[derive(Clone, Copy, Debug)] +pub enum PlayerInput { + Accelerate, + Brake, + Handbrake, + None, +} + +#[derive(Clone, Copy, Debug)] +pub enum Phase { + Playing, + /// Survived a crash with lives remaining: frozen behind a popup until the + /// player dismisses it (any key), then resumes into a brief recovery flash. + Crashed, + Finished { + elapsed_s: f32, + score: i64, + }, + Dead, +} + +#[derive(Clone, Debug)] +pub struct AiCar { + pub pos_m: f32, + pub speed_kmh: f32, + pub cruise_kmh: f32, + pub lane_idx: usize, + pub direction: TrafficDir, + pub height_rows: u8, +} + +/// Obstacle deterministically placed along a lane; cleared on crossing. +#[derive(Clone, Debug)] +pub struct SpawnedObstacle { + pub style: ObstacleStyle, + pub effects: &'static [ObstacleEffect], + pub crash: bool, + pub pos_m: f32, + pub lane_idx: usize, + pub triggered: bool, +} + +#[derive(Clone, Debug)] +pub struct RecentEffect { + pub label: &'static str, + pub at: Instant, +} + +/// All runtime traffic state — drives both the picker and the race. +pub struct State { + pub screen: TrafficScreen, + pub picker_selected_idx: usize, + pub best_scores: HashMap<&'static str, i64>, + pub best_score: i64, + + /// Persistence handle + owner; `None` in unit tests. + svc: Option, + user_id: uuid::Uuid, + + pub active_track: Option<&'static Track>, + pub current_stage_idx: usize, + pub stage_traveled_m: f32, + pub player_pos_m: f32, + pub player_speed_kmh: f32, + pub player_lane_idx: usize, + pub player_lane_display: f32, + pub input: PlayerInput, + pub input_last_set: Option, + pub ai_cars: Vec, + pub obstacles: Vec, + pub obstacle_seed_m: f32, + pub scenery_seed: u64, + pub elapsed_s: f32, + pub score: i64, + pub phase: Phase, + pub lives: u8, + pub is_paused: bool, + + pub recent_effects: VecDeque, + + pub gas_blocked_until: Option, + pub brake_blocked_until: Option, + pub wheel_blocked_until: Option, + + /// While set and in the future, the player car blinks (post-crash recovery). + pub flash_until: Option, +} + +impl State { + pub fn new() -> Self { + Self { + screen: TrafficScreen::Picker, + picker_selected_idx: 0, + best_scores: HashMap::new(), + best_score: 0, + svc: None, + user_id: uuid::Uuid::nil(), + active_track: None, + current_stage_idx: 0, + stage_traveled_m: 0.0, + player_pos_m: 0.0, + player_speed_kmh: Config::PLAYER_START_SPEED_KMH, + player_lane_idx: 0, + player_lane_display: 0.0, + input: PlayerInput::None, + input_last_set: None, + ai_cars: Vec::new(), + obstacles: Vec::new(), + obstacle_seed_m: 0.0, + scenery_seed: 0, + elapsed_s: 0.0, + score: 0, + phase: Phase::Playing, + lives: 0, + is_paused: false, + recent_effects: VecDeque::with_capacity(Config::RECENT_EFFECTS_CAPACITY), + gas_blocked_until: None, + brake_blocked_until: None, + wheel_blocked_until: None, + flash_until: None, + } + } + + /// Attach persistence and seed per-track bests loaded from the DB. Called + /// once at session start. `track_scores` is keyed by `Track::name`. + pub fn hydrate( + &mut self, + user_id: uuid::Uuid, + svc: super::svc::TrafficService, + track_scores: Vec, + high_score: Option, + ) { + self.user_id = user_id; + self.svc = Some(svc); + for ts in track_scores { + if let Some(track) = ALL_TRACKS.iter().find(|t| t.name == ts.track_key) { + self.best_scores.insert(track.name, ts.score as i64); + } + } + self.best_score = match high_score { + Some(hs) => hs.score as i64, + None => self.best_scores.values().copied().sum(), + }; + } + + // ─── Picker ────────────────────────────────────────────────────────────── + + pub fn picker_move(&mut self, delta: i32) { + let len = ALL_TRACKS.len() as i32; + if len == 0 { + return; + } + let cur = self.picker_selected_idx as i32; + self.picker_selected_idx = ((cur + delta).rem_euclid(len)) as usize; + } + + pub fn start_selected_track(&mut self) { + let track = ALL_TRACKS + .get(self.picker_selected_idx) + .copied() + .unwrap_or(DEFAULT_TRACK); + self.start_track(track); + } + + pub fn start_track(&mut self, track: &'static Track) { + self.active_track = Some(track); + self.current_stage_idx = 0; + self.stage_traveled_m = -Config::PRE_STAGE_M; + self.player_pos_m = -Config::PRE_STAGE_M; + self.player_speed_kmh = Config::PLAYER_START_SPEED_KMH; + self.player_lane_idx = track.stages[0].road.lanes.player_start_idx(); + self.player_lane_display = self.player_lane_idx as f32; + self.input = PlayerInput::None; + self.input_last_set = None; + self.ai_cars.clear(); + self.obstacles.clear(); + self.obstacle_seed_m = -Config::PRE_STAGE_M; + self.scenery_seed = random(); + self.elapsed_s = 0.0; + self.score = Track::SCORE_MAX; + self.phase = Phase::Playing; + self.lives = track.lives; + self.is_paused = false; + self.recent_effects.clear(); + self.gas_blocked_until = None; + self.brake_blocked_until = None; + self.wheel_blocked_until = None; + self.flash_until = None; + self.screen = TrafficScreen::Racing; + } + + pub fn restart_current(&mut self) { + if let Some(track) = self.active_track { + self.start_track(track); + } + } + + pub fn return_to_picker(&mut self) { + self.screen = TrafficScreen::Picker; + } + + /// Live 0..=SCORE_MAX grade: extrapolate the current average pace to the + /// full track and grade that projected completion time. Converges to the + /// real finish score as the run completes. + fn projected_grade(&self) -> i64 { + let Some(track) = self.active_track else { + return 0; + }; + let covered = self.player_pos_m; + if covered <= 0.0 || self.elapsed_s <= 0.0 { + return Track::SCORE_MAX; + } + let total_m = track.total_distance_km() * 1000.0 * self.distance_scale(); + if total_m <= 0.0 { + return 0; + } + let projected_time = self.elapsed_s * (total_m / covered); + track.grade_time(projected_time) + } + + // ─── Active track / stage accessors ────────────────────────────────────── + + pub fn track(&self) -> Option<&'static Track> { + self.active_track + } + + pub fn current_stage(&self) -> Option<&'static Stage> { + let track = self.active_track?; + track.stages.get(self.current_stage_idx) + } + + pub fn total_lanes(&self) -> usize { + self.current_stage() + .map(|s| s.road.lanes.total()) + .unwrap_or(0) + } + + pub fn lanes_incoming(&self) -> usize { + self.current_stage() + .map(|s| s.road.lanes.incoming.len()) + .unwrap_or(0) + } + + pub fn direction_of(&self, lane_idx: usize) -> TrafficDir { + if lane_idx < self.lanes_incoming() { + TrafficDir::Oncoming + } else { + TrafficDir::Same + } + } + + pub fn speed_scale(&self) -> f32 { + self.active_track.map(|t| t.speed_scale).unwrap_or(1.0) + } + + pub fn distance_scale(&self) -> f32 { + self.active_track.map(|t| t.distance_scale).unwrap_or(1.0) + } + + pub fn displayed_km_total(&self) -> f32 { + self.player_pos_m.max(0.0) / self.distance_scale() / 1000.0 + } + + pub fn displayed_km_stage(&self) -> f32 { + self.stage_traveled_m.max(0.0) / self.distance_scale() / 1000.0 + } + + pub fn track_total_km(&self) -> f32 { + self.active_track + .map(|t| t.total_distance_km()) + .unwrap_or(0.0) + } + + pub fn current_stage_km(&self) -> f32 { + self.current_stage().map(|s| s.distance_km).unwrap_or(0.0) + } + + // ─── Player control ─────────────────────────────────────────────────────── + + pub fn is_playing(&self) -> bool { + matches!(self.phase, Phase::Playing) && self.screen == TrafficScreen::Racing + } + + pub fn toggle_pause(&mut self) { + if matches!(self.phase, Phase::Playing) { + self.is_paused = !self.is_paused; + } + } + + pub fn move_left(&mut self) { + if !self.is_playing() || self.is_paused || self.wheels_blocked() { + return; + } + if self.player_lane_idx > 0 { + self.player_lane_idx -= 1; + } + } + + pub fn move_right(&mut self) { + if !self.is_playing() || self.is_paused || self.wheels_blocked() { + return; + } + if self.player_lane_idx + 1 < self.total_lanes() { + self.player_lane_idx += 1; + } + } + + fn wheels_blocked(&self) -> bool { + self.wheel_blocked_until.is_some_and(|t| Instant::now() < t) + } + + fn gas_blocked(&self) -> bool { + self.gas_blocked_until.is_some_and(|t| Instant::now() < t) + } + + fn brake_blocked(&self) -> bool { + self.brake_blocked_until.is_some_and(|t| Instant::now() < t) + } + + pub fn set_input(&mut self, input: PlayerInput) { + let allowed = match input { + PlayerInput::Accelerate => !self.gas_blocked(), + PlayerInput::Brake | PlayerInput::Handbrake => !self.brake_blocked(), + PlayerInput::None => true, + }; + if !allowed { + return; + } + self.input = input; + self.input_last_set = Some(Instant::now()); + } + + // ─── Tick ───────────────────────────────────────────────────────────────── + + pub fn tick(&mut self) { + if !self.is_playing() || self.is_paused { + return; + } + let dt = Config::TICK_DT; + + if let Some(t) = self.input_last_set + && t.elapsed() > Duration::from_millis(Config::INPUT_HOLD_MS) + { + self.input = PlayerInput::None; + self.input_last_set = None; + } + + let lane = self.current_lane_cfg(); + let (own_min, own_max, passive) = lane + .map(|l| (l.own_min_speed, l.own_max_speed, l.passive_decel)) + .unwrap_or((0.0, 200.0, 0.0)); + + match self.input { + PlayerInput::Accelerate => { + if self.player_speed_kmh < own_max { + self.player_speed_kmh = + (self.player_speed_kmh + Config::ACCEL_KMH_PER_S * dt).min(own_max); + } + } + PlayerInput::Brake => { + if self.player_speed_kmh > own_min { + self.player_speed_kmh = + (self.player_speed_kmh - Config::DECEL_KMH_PER_S * dt).max(own_min); + } + } + PlayerInput::Handbrake => { + if self.player_speed_kmh > own_min { + self.player_speed_kmh = + (self.player_speed_kmh - Config::DECEL_KMH_PER_S * 2.0 * dt).max(own_min); + } + } + PlayerInput::None => { + if passive > 0.0 && self.player_speed_kmh > own_min { + self.player_speed_kmh = (self.player_speed_kmh - passive * dt).max(own_min); + } + } + } + + // Ease speed back into the new lane's bounds after a lane change. + let step = Config::SPEED_CLAMP_PER_S * dt; + if self.player_speed_kmh > own_max { + self.player_speed_kmh = (self.player_speed_kmh - step).max(own_max); + } else if self.player_speed_kmh < own_min { + self.player_speed_kmh = (self.player_speed_kmh + step).min(own_min); + } + if self.player_speed_kmh < 0.0 { + self.player_speed_kmh = 0.0; + } + + let speed_scale = self.speed_scale(); + let player_step = self.player_speed_kmh / 3.6 * speed_scale * dt; + self.player_pos_m += player_step; + self.stage_traveled_m += player_step; + self.elapsed_s += dt; + self.score = self.projected_grade(); + + let target = self.player_lane_idx as f32; + let max_step = Config::LANE_TRANSITION_PER_S * dt; + let diff = target - self.player_lane_display; + if diff.abs() <= max_step { + self.player_lane_display = target; + } else { + self.player_lane_display += diff.signum() * max_step; + } + + self.update_ai(dt); + self.manage_ai(); + self.spawn_obstacles_ahead(); + self.check_obstacle_crossings(); + + // An obstacle crash may have ended play (Crashed/Dead); stop here so the + // later collision check can't spend a second life in the same tick. + if !matches!(self.phase, Phase::Playing) { + return; + } + + if let Some(stage) = self.current_stage() { + let stage_distance_m = stage.distance_km * 1000.0 * self.distance_scale(); + if self.stage_traveled_m >= stage_distance_m { + self.advance_stage(); + } + } + + if !self.is_flashing() && self.check_collision() { + self.handle_crash(); + return; + } + + if let Some(track) = self.active_track { + let total_m = track.total_distance_km() * 1000.0 * self.distance_scale(); + if self.player_pos_m >= total_m { + let score = track.grade_time(self.elapsed_s); + self.score = score; + self.phase = Phase::Finished { + elapsed_s: self.elapsed_s, + score, + }; + self.record_best(); + } + } + } + + fn current_lane_cfg(&self) -> Option<&'static super::track::Lane> { + let stage = self.current_stage()?; + stage.road.lanes.get(self.player_lane_idx) + } + + /// Record a finished run. Only completed tracks earn a per-track score; + /// crashing/dying yields nothing. The Traffic high score is the sum of all + /// per-track bests. + fn record_best(&mut self) { + let Some(track) = self.active_track else { + return; + }; + let Phase::Finished { score, .. } = self.phase else { + return; + }; + let entry = self.best_scores.entry(track.name).or_insert(0); + if score > *entry { + *entry = score; + if let Some(svc) = &self.svc { + svc.submit_track_score_task(self.user_id, track.name.to_string(), score as i32); + } + } + self.best_score = self.best_scores.values().copied().sum(); + } + + fn advance_stage(&mut self) { + let Some(track) = self.active_track else { + return; + }; + let stage_m = + track.stages[self.current_stage_idx].distance_km * 1000.0 * self.distance_scale(); + let overflow = (self.stage_traveled_m - stage_m).max(0.0); + let next_idx = self.current_stage_idx + 1; + if next_idx >= track.stages.len() { + return; + } + + let old_incoming = track.stages[self.current_stage_idx] + .road + .lanes + .incoming + .len(); + + self.current_stage_idx = next_idx; + self.stage_traveled_m = overflow; + + let new_incoming = track.stages[next_idx].road.lanes.incoming.len(); + let new_outgoing = track.stages[next_idx].road.lanes.outgoing.len(); + let new_total = new_incoming + new_outgoing; + + // Remap each AI car's lane_idx to the new stage's layout, preserving + // direction (incoming stays incoming, outgoing stays outgoing). Cars on + // a side that the new stage has no lanes for are dropped. + self.ai_cars.retain_mut(|car| { + if car.lane_idx < old_incoming { + if new_incoming == 0 { + return false; + } + car.lane_idx = car.lane_idx.min(new_incoming - 1); + car.direction = TrafficDir::Oncoming; + } else { + let outgoing_idx = car.lane_idx - old_incoming; + if new_outgoing == 0 { + return false; + } + car.lane_idx = new_incoming + outgoing_idx.min(new_outgoing - 1); + car.direction = TrafficDir::Same; + } + true + }); + + let new_outgoing_start = new_incoming; + if self.player_lane_idx >= new_total { + self.player_lane_idx = new_outgoing_start; + self.player_lane_display = self.player_lane_idx as f32; + } + + // Drop any car overlapping the player's position after the remap. + // Use a meter-based clearance: player spans ~CAR_HEIGHT rows above + // player_pos_m; clear a generous window so no instant collision fires. + let player_lane = self.player_lane_idx; + let player_pos = self.player_pos_m; + let clear_half = Config::INITIAL_SKIP_M * 0.5; + self.ai_cars.retain(|car| { + car.lane_idx != player_lane || (car.pos_m - player_pos).abs() > clear_half + }); + + self.obstacles.clear(); + self.obstacle_seed_m = self.player_pos_m; + + self.push_effect("new stage"); + } + + // ─── AI ────────────────────────────────────────────────────────────────── + + fn update_ai(&mut self, dt: f32) { + let follow_gap_m = Config::AI_FOLLOW_GAP_ROWS as f32 * Config::METERS_PER_ROW; + let speed_scale = self.speed_scale(); + + let snap: Vec<(f32, usize, TrafficDir, f32, f32)> = self + .ai_cars + .iter() + .map(|c| { + let half_m = c.height_rows as f32 * Config::METERS_PER_ROW * 0.5; + (c.pos_m, c.lane_idx, c.direction, c.speed_kmh, half_m) + }) + .collect(); + + for (i, car) in self.ai_cars.iter_mut().enumerate() { + let my_half = car.height_rows as f32 * Config::METERS_PER_ROW * 0.5; + let my_front = match car.direction { + TrafficDir::Same => car.pos_m + my_half, + TrafficDir::Oncoming => car.pos_m - my_half, + }; + + let mut nearest: Option<(f32, f32)> = None; + for (j, &(jpos, jlane, jdir, jspeed, jhalf)) in snap.iter().enumerate() { + if i == j || jlane != car.lane_idx { + continue; + } + let j_back = match jdir { + TrafficDir::Same => jpos - jhalf, + TrafficDir::Oncoming => jpos + jhalf, + }; + let gap = match car.direction { + TrafficDir::Same => j_back - my_front, + TrafficDir::Oncoming => my_front - j_back, + }; + if gap <= 0.0 { + continue; + } + if nearest.is_none_or(|(g, _)| gap < g) { + nearest = Some((gap, jspeed)); + } + } + + car.speed_kmh = match nearest { + Some((gap, lspeed)) if gap < follow_gap_m && lspeed < car.cruise_kmh => lspeed, + _ => car.cruise_kmh, + }; + + let step = car.speed_kmh / 3.6 * speed_scale * dt; + match car.direction { + TrafficDir::Same => car.pos_m += step, + TrafficDir::Oncoming => car.pos_m -= step, + } + } + } + + fn check_collision(&self) -> bool { + let p_top = Config::PLAYER_TOP_ROW as i32; + let p_bot = p_top + Config::CAR_HEIGHT_ROWS as i32 - 1; + for car in &self.ai_cars { + if car.lane_idx != self.player_lane_idx { + continue; + } + let center = self.track_to_screen_row(car.pos_m); + let h = car.height_rows as i32; + let top = center - h / 2; + let bot = top + h - 1; + if top <= p_bot && bot >= p_top { + return true; + } + } + false + } + + /// Handle a crash (AI collision or crash obstacle). Spends one life; if any + /// remain the run continues in place with the surrounding cars in the + /// player's lane cleared so no instant re-crash fires. At zero lives the + /// run ends. + fn handle_crash(&mut self) { + self.lives = self.lives.saturating_sub(1); + if self.lives == 0 { + self.phase = Phase::Dead; + self.record_best(); + return; + } + let player_lane = self.player_lane_idx; + let player_pos = self.player_pos_m; + let clear_half = Config::INITIAL_SKIP_M * 0.5; + self.ai_cars.retain(|car| { + car.lane_idx != player_lane || (car.pos_m - player_pos).abs() > clear_half + }); + self.push_effect("crash! -1 life"); + self.phase = Phase::Crashed; + } + + pub fn is_crashed(&self) -> bool { + matches!(self.phase, Phase::Crashed) && self.screen == TrafficScreen::Racing + } + + /// Dismiss the crash popup: resume play and start the recovery flash. + pub fn resume_from_crash(&mut self) { + if !matches!(self.phase, Phase::Crashed) { + return; + } + self.phase = Phase::Playing; + self.flash_until = Some(Instant::now() + Duration::from_millis(Config::CRASH_FLASH_MS)); + self.input = PlayerInput::None; + self.input_last_set = None; + } + + /// Post-crash recovery window: the car blinks and is immune to crashes. + pub fn is_flashing(&self) -> bool { + self.flash_until.is_some_and(|t| Instant::now() < t) + } + + /// Whether the player car should be drawn this frame. Solid normally; during + /// the recovery window it blinks on/off. + pub fn player_visible(&self) -> bool { + match self.flash_until { + Some(t) => { + let now = Instant::now(); + if now >= t { + return true; + } + let remaining_ms = (t - now).as_millis(); + (remaining_ms / 150).is_multiple_of(2) + } + None => true, + } + } + + fn manage_ai(&mut self) { + let Some(stage) = self.current_stage() else { + return; + }; + let lanes = stage.road.lanes; + let total_lanes = lanes.total(); + if total_lanes == 0 { + return; + } + + let player_pos = self.player_pos_m; + + // Despawn cars that fell too far behind or are in lanes that no longer exist. + self.ai_cars.retain(|car| { + car.pos_m > player_pos - Config::AI_DESPAWN_BEHIND_M && car.lane_idx < total_lanes + }); + + // Initial fill: on the very first tick of a race the car list is empty. + // Populate the whole lookahead (skipping a safety gap near the player) + // so the road isn't bare at start. Ongoing: spawn only beyond the + // minimap so new cars scroll in naturally from ahead. + let is_initial_fill = self.ai_cars.is_empty(); + let spawn_min = player_pos + + if is_initial_fill { + Config::INITIAL_SKIP_M + } else { + Config::MINIMAP_RANGE_M + }; + let spawn_max = player_pos + Config::SPAWN_AHEAD_M; + + if spawn_max <= spawn_min { + return; + } + + let mut rng = rand::thread_rng(); + + // Count current cars per lane. + let mut per_lane = vec![0usize; total_lanes]; + for c in &self.ai_cars { + per_lane[c.lane_idx] += 1; + } + + for (lane_idx, &lane_count) in per_lane.iter().enumerate().take(total_lanes) { + let lane = lanes.get(lane_idx).expect("idx in range"); + let target = density_to_count(lane.traffic_density); + if lane_count >= target { + continue; + } + + let direction = self.direction_of(lane_idx); + let needed = target - lane_count; + + // Initial fill: place as many as needed (up to MAX_ATTEMPTS tries). + // Ongoing: place at most one car per manage_ai call to keep it smooth. + let to_place = if is_initial_fill { needed } else { 1 }; + let max_attempts = to_place * 6; + let mut placed = 0; + + for _ in 0..max_attempts { + if placed >= to_place { + break; + } + let pos = spawn_min + rng.r#gen::() * (spawn_max - spawn_min); + if !self.spawn_clear(pos, lane_idx, direction) { + continue; + } + let cruise = rng.gen_range( + lane.traffic_min_speed + ..=lane.traffic_max_speed.max(lane.traffic_min_speed + 1.0), + ); + self.ai_cars.push(AiCar { + pos_m: pos, + speed_kmh: cruise, + cruise_kmh: cruise, + lane_idx, + direction, + height_rows: pick_car_height(lane.traffic_cars, &mut rng), + }); + placed += 1; + } + } + } + + fn spawn_clear(&self, pos: f32, lane_idx: usize, dir: TrafficDir) -> bool { + self.ai_cars.iter().all(|o| { + o.lane_idx != lane_idx + || o.direction != dir + || (o.pos_m - pos).abs() >= Config::AI_MIN_SEPARATION_M + }) + } + + // ─── Obstacles ──────────────────────────────────────────────────────────── + + fn spawn_obstacles_ahead(&mut self) { + let Some(stage) = self.current_stage() else { + return; + }; + let lanes = stage.road.lanes; + let target_max_m = self.player_pos_m + Config::MINIMAP_RANGE_M; + + const SLOT_M: f32 = 30.0; + while self.obstacle_seed_m < target_max_m { + let slot = (self.obstacle_seed_m / SLOT_M) as i64 ^ self.scenery_seed as i64; + for lane_idx in 0..lanes.total() { + let lane = match lanes.get(lane_idx) { + Some(l) => l, + None => continue, + }; + for ob in lane.obstacles { + let h = hash3(slot, lane_idx as i64, ob.style.id); + let p = (h % 10_000) as f32 / 10_000.0; + if p < ob.frequency { + let pos = self.obstacle_seed_m + (h % SLOT_M as u64) as f32; + if self.is_near_stage_boundary(pos) { + continue; + } + self.obstacles.push(SpawnedObstacle { + style: ob.style, + effects: ob.effects, + crash: ob.has_crash(), + pos_m: pos, + lane_idx, + triggered: false, + }); + } + } + } + self.obstacle_seed_m += SLOT_M; + } + + let cutoff = self.player_pos_m - Config::AI_DESPAWN_BEHIND_M; + self.obstacles.retain(|o| o.pos_m > cutoff); + } + + fn check_obstacle_crossings(&mut self) { + let p_front = self.player_pos_m; + let p_back = self.player_pos_m - Config::CAR_HEIGHT_ROWS as f32 * Config::METERS_PER_ROW; + + let mut triggered: Vec<(&'static str, &'static [ObstacleEffect])> = Vec::new(); + for obs in self.obstacles.iter_mut() { + if obs.triggered || obs.lane_idx != self.player_lane_idx { + continue; + } + if obs.pos_m >= p_back && obs.pos_m <= p_front { + obs.triggered = true; + triggered.push((obs.style.label, obs.effects)); + } + } + + for (label, effects) in triggered { + self.push_effect(label); + for &eff in effects { + self.apply_effect(eff); + } + } + } + + fn apply_effect(&mut self, eff: ObstacleEffect) { + match eff { + ObstacleEffect::Crash => { + if !self.is_flashing() { + self.handle_crash(); + } + } + ObstacleEffect::SpeedChange { affect } => { + self.player_speed_kmh = (self.player_speed_kmh * (1.0 + affect)).max(0.0); + } + ObstacleEffect::BlockGas { cooldown_ms } => { + self.gas_blocked_until = + Some(Instant::now() + Duration::from_millis(cooldown_ms as u64)); + } + ObstacleEffect::BlockBrakes { cooldown_ms } => { + self.brake_blocked_until = + Some(Instant::now() + Duration::from_millis(cooldown_ms as u64)); + } + ObstacleEffect::BlockWheels { cooldown_ms } => { + self.wheel_blocked_until = + Some(Instant::now() + Duration::from_millis(cooldown_ms as u64)); + } + } + } + + /// Push a labelled effect into the right-panel log, capping at capacity. + fn push_effect(&mut self, label: &'static str) { + self.recent_effects.push_front(RecentEffect { + label, + at: Instant::now(), + }); + self.recent_effects + .truncate(Config::RECENT_EFFECTS_CAPACITY); + } + + // ─── Geometry helpers (used by ui.rs) ──────────────────────────────────── + + pub fn track_to_screen_row(&self, track_pos_m: f32) -> i32 { + let offset_m = self.player_pos_m - track_pos_m; + Config::PLAYER_TOP_ROW as i32 + (offset_m / Config::METERS_PER_ROW) as i32 + } + + pub fn progress_pct(&self) -> f32 { + let Some(track) = self.active_track else { + return 0.0; + }; + let total_m = track.total_distance_km() * 1000.0 * self.distance_scale(); + if total_m == 0.0 { + return 0.0; + } + (self.player_pos_m.max(0.0) / total_m * 100.0).clamp(0.0, 100.0) + } + + pub fn elapsed_formatted(&self) -> String { + let total = self.elapsed_s as u32; + let mins = total / 60; + let secs = total % 60; + let tenth = ((self.elapsed_s - total as f32) * 10.0) as u32; + format!("{}:{:02}.{}", mins, secs, tenth) + } + + /// Absolute `player_pos_m` value at which stage `idx` begins. + /// Stage 0 always starts at 0 (the pre-stage zone is before that). + pub fn stage_start_pos_m(&self, idx: usize) -> f32 { + let Some(track) = self.active_track else { + return 0.0; + }; + let scale = self.distance_scale(); + track.stages[..idx] + .iter() + .map(|s| s.distance_km * 1000.0 * scale) + .sum() + } + + fn is_near_stage_boundary(&self, pos: f32) -> bool { + let Some(track) = self.active_track else { + return false; + }; + let scale = self.distance_scale(); + let mut boundary = 0.0f32; + for stage in track.stages { + if (pos - boundary).abs() < Config::STAGE_CLEAR_M { + return true; + } + boundary += stage.distance_km * 1000.0 * scale; + } + (pos - boundary).abs() < Config::STAGE_CLEAR_M + } +} + +impl Default for State { + fn default() -> Self { + Self::new() + } +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +fn density_to_count(density: f32) -> usize { + let d = density.clamp(0.0, 1.0); + (d * Config::SPAWN_AHEAD_M / Config::AI_MIN_SEPARATION_M).round() as usize +} + +fn pick_car_height(cars: &[super::track::Car], rng: &mut impl Rng) -> u8 { + if cars.is_empty() { + return 3; + } + let total: f32 = cars.iter().map(|c| c.incidence).sum(); + if total <= 0.0 { + return cars[0].height; + } + let mut r: f32 = rng.r#gen::() * total; + for c in cars { + if r < c.incidence { + return c.height; + } + r -= c.incidence; + } + cars[cars.len() - 1].height +} + +/// Cheap deterministic 3-input hash for obstacle placement seeding. +/// Exported to `ui.rs` for scenery placement (avoids duplicate). +pub(super) fn hash3(a: i64, b: i64, c: i64) -> u64 { + let mut x = (a as u64).wrapping_mul(0x9E3779B97F4A7C15); + x ^= (b as u64).wrapping_mul(0xC2B2AE3D27D4EB4F); + x = x.rotate_left(31); + x ^= (c as u64).wrapping_mul(0x165667B19E3779F9); + x.wrapping_mul(0x94D049BB133111EB) +} + +// ─── Unit tests ────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::arcade::traffic::tracks::DEFAULT_TRACK; + + #[test] + fn picker_starts_with_zero_score() { + let s = State::new(); + assert_eq!(s.screen, TrafficScreen::Picker); + assert_eq!(s.best_score, 0); + } + + #[test] + fn start_track_moves_to_racing() { + let mut s = State::new(); + s.start_track(DEFAULT_TRACK); + assert_eq!(s.screen, TrafficScreen::Racing); + assert_eq!(s.current_stage_idx, 0); + assert_eq!( + s.player_lane_idx, + DEFAULT_TRACK.stages[0].road.lanes.player_start_idx() + ); + } + + #[test] + fn move_left_then_right_returns_to_origin() { + let mut s = State::new(); + s.start_track(DEFAULT_TRACK); + let start = s.player_lane_idx; + s.move_left(); + assert!(s.player_lane_idx < start); + s.move_right(); + assert_eq!(s.player_lane_idx, start); + } + + #[test] + fn cannot_drive_above_lane_max_for_long() { + let mut s = State::new(); + s.start_track(DEFAULT_TRACK); + let lane = s.current_lane_cfg().unwrap(); + s.player_speed_kmh = lane.own_max_speed + 50.0; + for _ in 0..30 { + s.tick(); + } + let lane = s.current_lane_cfg().unwrap(); + assert!(s.player_speed_kmh <= lane.own_max_speed + 1.0); + } +} diff --git a/late-ssh/src/app/arcade/traffic/svc.rs b/late-ssh/src/app/arcade/traffic/svc.rs new file mode 100644 index 00000000..30683ea1 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/svc.rs @@ -0,0 +1,61 @@ +//! DB-backed persistence for Traffic per-track scores and the aggregate high +//! score (sum of a user's per-track bests). + +use anyhow::Result; +use late_core::db::Db; +use late_core::models::traffic::{HighScore, TrackScore}; +use tokio::sync::broadcast; +use uuid::Uuid; + +use crate::app::activity::event::{ActivityEvent, ActivityGame}; +use crate::app::activity::publisher::ActivityPublisher; + +#[derive(Clone)] +pub struct TrafficService { + db: Db, + activity: Option, +} + +impl TrafficService { + pub fn new(db: Db) -> Self { + Self { db, activity: None } + } + + pub fn with_activity_feed(mut self, activity_feed: broadcast::Sender) -> Self { + self.activity = Some(ActivityPublisher::new(self.db.clone(), activity_feed)); + self + } + + pub async fn load_track_scores(&self, user_id: Uuid) -> Result> { + let client = self.db.get().await?; + TrackScore::list_for_user(&client, user_id).await + } + + pub async fn load_high_score(&self, user_id: Uuid) -> Result> { + let client = self.db.get().await?; + HighScore::find_by_user_id(&client, user_id).await + } + + /// Persist one finished track's score (kept only if higher), recompute the + /// aggregate total, record a score event, and publish quest activity. + pub fn submit_track_score_task(&self, user_id: Uuid, track_key: String, score: i32) { + let svc = self.clone(); + tokio::spawn(async move { + if let Err(e) = svc.submit_track_score(user_id, track_key, score).await { + tracing::error!(error = ?e, "failed to submit traffic track score"); + } + }); + } + + async fn submit_track_score(&self, user_id: Uuid, track_key: String, score: i32) -> Result<()> { + let mut client = self.db.get().await?; + let total = + HighScore::update_track_score_if_higher(&mut client, user_id, &track_key, score) + .await?; + HighScore::record_score_event(&client, user_id, total).await?; + if let Some(activity) = &self.activity { + activity.game_scored_task(user_id, ActivityGame::Traffic, total, None); + } + Ok(()) + } +} diff --git a/late-ssh/src/app/arcade/traffic/theme.rs b/late-ssh/src/app/arcade/traffic/theme.rs new file mode 100644 index 00000000..08f68539 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/theme.rs @@ -0,0 +1,821 @@ +//! Standard style library for the Traffic game. +//! +//! Every `pub const` in this module is a ready-made instance of one of the +//! style descriptor types defined in `track.rs`. Track authors import these by +//! name and compose them via struct-update syntax or inline overrides. +//! +//! Adding a **new standard look** means adding a constant here. +//! Adding a **new [`Theme`] variant** means adding a branch to `tint` and to +//! `Theme::name` / `Theme::icon` in `track.rs`. +//! +//! Nothing in this file is mandatory. A track can define its own style +//! instances inline without touching this file at all. + +use ratatui::style::Color; + +use super::track::{ + Cell, DividerStyle, LaneStyle, ObjectStyle, ObstacleStyle, SceneryStyle, ShoulderStyle, Sprite, + Theme, +}; + +// ─── Theme tint ────────────────────────────────────────────────────────────── + +/// Multiplicative colour tint for the active theme. Applied inside each style +/// fn so callers never have to think about it. +pub fn tint(c: Color, theme: Theme) -> Color { + let Color::Rgb(r, g, b) = c else { + return c; + }; + match theme { + Theme::Standard => c, + Theme::Winter => Color::Rgb( + ((r as u16 + 60).min(255)) as u8, + ((g as u16 + 60).min(255)) as u8, + ((b as u16 + 80).min(255)) as u8, + ), + Theme::Desert => Color::Rgb( + ((r as u16 * 11 / 10).min(255)) as u8, + ((g as u16 * 9 / 10).min(255)) as u8, + ((b as u16 * 7 / 10).min(255)) as u8, + ), + } +} + +// ─── Trunk colour (shared by tree-bearing ObjectStyles) ───────────────────── + +const TRUNK_BROWN: Color = Color::Rgb(110, 70, 30); + +pub fn trunk_color(theme: Theme) -> Color { + tint(TRUNK_BROWN, theme) +} + +// ─── Stage icons ───────────────────────────────────────────────────────────── + +pub const STAGE_METROPOLIS: &str = "🏙"; +pub const STAGE_CITY: &str = "🏢"; +pub const STAGE_CITY_OUTSKIRTS: &str = "🏘"; +pub const STAGE_VILLAGE: &str = "🏡"; +pub const STAGE_HIGHWAY: &str = "🛣"; +pub const STAGE_WILD_PLAINS: &str = "🌾"; +pub const STAGE_WILD_HILLS: &str = "⛰"; +pub const STAGE_WILD_FOREST: &str = "🌲"; +pub const STAGE_SLOPE_UP: &str = "↗"; +pub const STAGE_SLOPE_DOWN: &str = "↘"; +pub const STAGE_SPECIAL: &str = "★"; +pub const STAGE_DESERT: &str = "🏜"; +pub const STAGE_MOUNTAIN: &str = "🏔"; +pub const STAGE_COASTAL: &str = "🌊"; +pub const STAGE_BRIDGE: &str = "🌉"; +pub const STAGE_TUNNEL: &str = "🕳"; +pub const STAGE_CASTLE: &str = "🏰"; +pub const STAGE_DRAGON: &str = "🐉"; +pub const STAGE_MOON: &str = "🌙"; +pub const STAGE_PLANET: &str = "🪐"; +pub const STAGE_GALAXY: &str = "🌌"; +pub const STAGE_CHAOS: &str = "💀"; + +// ─── Lane styles ───────────────────────────────────────────────────────────── + +const ASPHALT_BG: Color = Color::Rgb(18, 18, 18); +const ASPHALT_PREMIUM_BG: Color = Color::Rgb(28, 28, 32); +const ASPHALT_PATCHY_BG: Color = Color::Rgb(22, 20, 18); +const ASPHALT_PATCH_FG: Color = Color::Rgb(70, 65, 60); +const GRAVEL_BG: Color = Color::Rgb(70, 60, 48); +const GRAVEL_FG: Color = Color::Rgb(120, 105, 90); +const DIRT_BG: Color = Color::Rgb(82, 56, 36); +const DIRT_FG: Color = Color::Rgb(120, 90, 60); +const LANE_GRASS_BG: Color = Color::Rgb(28, 60, 28); +const LANE_GRASS_FG: Color = Color::Rgb(70, 110, 60); + +pub const LANE_ASPHALT_PREMIUM: LaneStyle = LaneStyle { + cell: |theme, _row, _col| { + let bg = tint(ASPHALT_PREMIUM_BG, theme); + Cell::new(" ", bg, bg) + }, + bg: |theme| tint(ASPHALT_PREMIUM_BG, theme), +}; + +pub const LANE_ASPHALT_STANDARD: LaneStyle = LaneStyle { + cell: |theme, _row, _col| { + let bg = tint(ASPHALT_BG, theme); + Cell::new(" ", bg, bg) + }, + bg: |theme| tint(ASPHALT_BG, theme), +}; + +pub const LANE_ASPHALT_PATCHY: LaneStyle = LaneStyle { + cell: |theme, row, _col| { + let bg = tint(ASPHALT_PATCHY_BG, theme); + if row.rem_euclid(7) == 0 { + Cell::new(".", tint(ASPHALT_PATCH_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(ASPHALT_PATCHY_BG, theme), +}; + +pub const LANE_GRAVEL: LaneStyle = LaneStyle { + cell: |theme, row, _col| { + let bg = tint(GRAVEL_BG, theme); + if row.rem_euclid(3) == 0 { + Cell::new(",", tint(GRAVEL_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(GRAVEL_BG, theme), +}; + +pub const LANE_DIRT: LaneStyle = LaneStyle { + cell: |theme, row, _col| { + let bg = tint(DIRT_BG, theme); + if row.rem_euclid(5) < 2 { + Cell::new("·", tint(DIRT_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(DIRT_BG, theme), +}; + +pub const LANE_GRASS: LaneStyle = LaneStyle { + cell: |theme, row, _col| { + let bg = tint(LANE_GRASS_BG, theme); + if row.rem_euclid(6) == 0 { + Cell::new("v", tint(LANE_GRASS_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(LANE_GRASS_BG, theme), +}; + +const COBBLE_BG: Color = Color::Rgb(75, 70, 65); +const COBBLE_FG: Color = Color::Rgb(130, 118, 106); +const CRACKED_BG: Color = Color::Rgb(17, 15, 13); +const CRACK_FG: Color = Color::Rgb(60, 52, 44); + +pub const LANE_COBBLESTONE: LaneStyle = LaneStyle { + cell: |theme, row, col| { + let bg = tint(COBBLE_BG, theme); + if (row.wrapping_add(col as i32)).rem_euclid(3) == 0 { + Cell::new("▪", tint(COBBLE_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(COBBLE_BG, theme), +}; + +pub const LANE_ASPHALT_CRACKED: LaneStyle = LaneStyle { + cell: |theme, row, col| { + let bg = tint(CRACKED_BG, theme); + let c = col as i32; + if (row.rem_euclid(6) == 0 && c.rem_euclid(7) < 2) + || (row.rem_euclid(11) == 4 && c.rem_euclid(5) == 0) + { + Cell::new("╌", tint(CRACK_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(CRACKED_BG, theme), +}; + +const CRYSTAL_LANE_BG: Color = Color::Rgb(12, 28, 52); +const CRYSTAL_LANE_FG: Color = Color::Rgb(85, 190, 230); +const SPACE_ROAD_BG: Color = Color::Rgb(5, 7, 20); +const SPACE_ROAD_FG: Color = Color::Rgb(22, 30, 70); +const NEON_ROAD_BG: Color = Color::Rgb(5, 3, 20); +const NEON_ROAD_FG: Color = Color::Rgb(0, 240, 160); + +pub const LANE_CRYSTAL: LaneStyle = LaneStyle { + cell: |theme, row, col| { + let bg = tint(CRYSTAL_LANE_BG, theme); + if (row + col as i32).rem_euclid(8) == 0 { + Cell::new("✧", tint(CRYSTAL_LANE_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(CRYSTAL_LANE_BG, theme), +}; + +pub const LANE_SPACE: LaneStyle = LaneStyle { + cell: |theme, row, col| { + let bg = tint(SPACE_ROAD_BG, theme); + if row.rem_euclid(10) == 0 && (col as i32).rem_euclid(4) == 0 { + Cell::new("·", tint(SPACE_ROAD_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(SPACE_ROAD_BG, theme), +}; + +pub const LANE_NEON: LaneStyle = LaneStyle { + cell: |theme, row, _col| { + let bg = tint(NEON_ROAD_BG, theme); + if row.rem_euclid(6) == 0 { + Cell::new("·", tint(NEON_ROAD_FG, theme), bg) + } else { + Cell::new(" ", bg, bg) + } + }, + bg: |theme| tint(NEON_ROAD_BG, theme), +}; + +// ─── Divider styles ────────────────────────────────────────────────────────── + +const YELLOW: Color = Color::Rgb(220, 180, 30); +const WHITE: Color = Color::Rgb(180, 180, 180); +const FAINT: Color = Color::Rgb(85, 85, 85); + +pub const DIV_YELLOW_DOUBLE: DividerStyle = DividerStyle { + cell: |_row, bg| Cell::new("‖", YELLOW, bg), +}; +pub const DIV_YELLOW_SINGLE: DividerStyle = DividerStyle { + cell: |_row, bg| Cell::new("│", YELLOW, bg), +}; +pub const DIV_YELLOW_DASH: DividerStyle = DividerStyle { + cell: |row, bg| { + let sym = if row.rem_euclid(4) < 2 { "│" } else { " " }; + Cell::new(sym, YELLOW, bg) + }, +}; +pub const DIV_YELLOW_DOTS: DividerStyle = DividerStyle { + cell: |row, bg| { + let sym = if row.rem_euclid(3) == 0 { "·" } else { " " }; + Cell::new(sym, YELLOW, bg) + }, +}; +pub const DIV_WHITE_SINGLE: DividerStyle = DividerStyle { + cell: |_row, bg| Cell::new("│", WHITE, bg), +}; +pub const DIV_WHITE_DASH: DividerStyle = DividerStyle { + cell: |row, bg| { + let sym = if row.rem_euclid(4) < 2 { "│" } else { " " }; + Cell::new(sym, WHITE, bg) + }, +}; +pub const DIV_WHITE_DOTS: DividerStyle = DividerStyle { + cell: |row, bg| { + let sym = if row.rem_euclid(3) == 0 { "·" } else { " " }; + Cell::new(sym, WHITE, bg) + }, +}; +pub const DIV_FAINT: DividerStyle = DividerStyle { + cell: |_row, bg| Cell::new("│", FAINT, bg), +}; +pub const DIV_NONE: DividerStyle = DividerStyle { + cell: |_row, bg| Cell::new(" ", bg, bg), +}; + +// ─── Scenery background styles ─────────────────────────────────────────────── + +const CONCRETE_BG: Color = Color::Rgb(60, 60, 60); +const SC_GRASS_BG: Color = Color::Rgb(20, 55, 20); +const SC_DIRT_BG: Color = Color::Rgb(70, 50, 30); +const VOID_BG: Color = Color::Rgb(2, 2, 6); +const SAND_BG: Color = Color::Rgb(175, 150, 80); +const SNOW_BG: Color = Color::Rgb(195, 208, 218); +const WATER_BG: Color = Color::Rgb(28, 78, 155); + +pub const SCENERY_CONCRETE: SceneryStyle = SceneryStyle { + bg: |theme| tint(CONCRETE_BG, theme), +}; +pub const SCENERY_GRASS: SceneryStyle = SceneryStyle { + bg: |theme| tint(SC_GRASS_BG, theme), +}; +pub const SCENERY_DIRT: SceneryStyle = SceneryStyle { + bg: |theme| tint(SC_DIRT_BG, theme), +}; +pub const SCENERY_VOID: SceneryStyle = SceneryStyle { + bg: |theme| tint(VOID_BG, theme), +}; +pub const SCENERY_SAND: SceneryStyle = SceneryStyle { + bg: |theme| tint(SAND_BG, theme), +}; +pub const SCENERY_SNOW: SceneryStyle = SceneryStyle { + bg: |theme| tint(SNOW_BG, theme), +}; +pub const SCENERY_WATER: SceneryStyle = SceneryStyle { + bg: |theme| tint(WATER_BG, theme), +}; + +const MAGIC_BG: Color = Color::Rgb(42, 12, 68); +const LAVA_BG: Color = Color::Rgb(95, 28, 8); +const STARFIELD_BG: Color = Color::Rgb(3, 3, 12); + +pub const SCENERY_MAGIC: SceneryStyle = SceneryStyle { + bg: |theme| tint(MAGIC_BG, theme), +}; +pub const SCENERY_LAVA: SceneryStyle = SceneryStyle { + bg: |theme| tint(LAVA_BG, theme), +}; +pub const SCENERY_STARFIELD: SceneryStyle = SceneryStyle { + bg: |theme| tint(STARFIELD_BG, theme), +}; + +// ─── Object styles ─────────────────────────────────────────────────────────── + +const TREE_PINE_GREEN: Color = Color::Rgb(55, 175, 55); +const TREE_OAK_GREEN: Color = Color::Rgb(80, 160, 70); +const TREE_PALM_GREEN: Color = Color::Rgb(120, 180, 70); +const FLOWER_PINK: Color = Color::Rgb(200, 110, 160); +const BUSH_GREEN: Color = Color::Rgb(70, 130, 60); +const GRASS_BLADE: Color = Color::Rgb(70, 130, 70); +const BUILDING_GRAY: Color = Color::Rgb(140, 140, 140); +const STAR_WHITE: Color = Color::Rgb(220, 220, 220); + +pub const OBJ_GRASS: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["ʷ"]], + fg: tint(GRASS_BLADE, theme), + }, + has_trunk: false, +}; +pub const OBJ_BUSH: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["❀"]], + fg: tint(BUSH_GREEN, theme), + }, + has_trunk: false, +}; +pub const OBJ_TREE_PINE: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["▲"], &["▲"], &["│"]], + fg: tint(TREE_PINE_GREEN, theme), + }, + has_trunk: true, +}; +pub const OBJ_TREE_OAK: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["◉"], &["│"]], + fg: tint(TREE_OAK_GREEN, theme), + }, + has_trunk: true, +}; +pub const OBJ_TREE_PALM: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["✤"], &["│"]], + fg: tint(TREE_PALM_GREEN, theme), + }, + has_trunk: true, +}; +pub const OBJ_BUILDING_HOUSE: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&["╱", "▔", "╲"], &["│", "░", "│"]], + fg: tint(BUILDING_GRAY, theme), + }, + has_trunk: false, +}; +pub const OBJ_BUILDING_APARTMENTS: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&["│", "▦", "│"], &["│", "▦", "│"], &["│", "░", "│"]], + fg: tint(BUILDING_GRAY, theme), + }, + has_trunk: false, +}; +pub const OBJ_SKYSCRAPER: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[ + &[" ", "▲", " "], + &["│", "▦", "│"], + &["│", "▦", "│"], + &["│", "▦", "│"], + &["│", "▦", "│"], + &["│", "░", "│"], + ], + fg: tint(BUILDING_GRAY, theme), + }, + has_trunk: false, +}; +pub const OBJ_FLOWER: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["✿"]], + fg: tint(FLOWER_PINK, theme), + }, + has_trunk: false, +}; +pub const OBJ_STAR: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["✦"]], + fg: tint(STAR_WHITE, theme), + }, + has_trunk: false, +}; + +const CACTUS_GREEN: Color = Color::Rgb(55, 135, 55); +const ROCK_GRAY: Color = Color::Rgb(130, 120, 108); +const BARN_RED: Color = Color::Rgb(175, 55, 45); +const STEEL_FG: Color = Color::Rgb(155, 168, 178); +const CHURCH_STONE: Color = Color::Rgb(158, 152, 142); +const VINE_GREEN: Color = Color::Rgb(65, 135, 48); +const BILLBOARD_FG: Color = Color::Rgb(200, 200, 118); + +pub const OBJ_CACTUS: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["Ψ"], &["│"]], + fg: tint(CACTUS_GREEN, theme), + }, + has_trunk: true, +}; +pub const OBJ_ROCK: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 2, + glyphs: &[&["◢", "◣"]], + fg: tint(ROCK_GRAY, theme), + }, + has_trunk: false, +}; +pub const OBJ_BARN: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&["╱", "▲", "╲"], &["│", "▓", "│"]], + fg: tint(BARN_RED, theme), + }, + has_trunk: false, +}; +pub const OBJ_WINDMILL: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["✛"], &["│"]], + fg: tint(STEEL_FG, theme), + }, + has_trunk: false, +}; +pub const OBJ_CHURCH: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&[" ", "†", " "], &["│", "▦", "│"], &["│", "░", "│"]], + fg: tint(CHURCH_STONE, theme), + }, + has_trunk: false, +}; +pub const OBJ_VINEYARD: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["♣"]], + fg: tint(VINE_GREEN, theme), + }, + has_trunk: false, +}; +pub const OBJ_BILLBOARD: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&["┌", "▬", "┐"], &["└", "─", "┘"]], + fg: tint(BILLBOARD_FG, theme), + }, + has_trunk: false, +}; + +const MUSHROOM_RED: Color = Color::Rgb(200, 55, 45); +const CRYSTAL_OBJ_FG: Color = Color::Rgb(100, 200, 235); +const DARK_TOWER_FG: Color = Color::Rgb(78, 58, 95); +const PLANET_BLUE: Color = Color::Rgb(45, 95, 200); +const NEBULA_FG: Color = Color::Rgb(155, 75, 200); +const COMET_FG: Color = Color::Rgb(235, 235, 170); + +pub const OBJ_MUSHROOM: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 2, + glyphs: &[&["◖", "◗"], &["└", "┘"]], + fg: tint(MUSHROOM_RED, theme), + }, + has_trunk: false, +}; +pub const OBJ_CRYSTAL_SPIKE: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["◆"], &["◇"]], + fg: tint(CRYSTAL_OBJ_FG, theme), + }, + has_trunk: false, +}; +pub const OBJ_DARK_TOWER: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 3, + glyphs: &[&[" ", "▲", " "], &["▐", "▓", "▌"], &["▐", "█", "▌"]], + fg: tint(DARK_TOWER_FG, theme), + }, + has_trunk: false, +}; +pub const OBJ_PLANET_SMALL: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 2, + glyphs: &[&["◑", "◐"]], + fg: tint(PLANET_BLUE, theme), + }, + has_trunk: false, +}; +pub const OBJ_NEBULA: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["≋"]], + fg: tint(NEBULA_FG, theme), + }, + has_trunk: false, +}; +pub const OBJ_COMET: ObjectStyle = ObjectStyle { + sprite: |theme| Sprite { + width: 1, + glyphs: &[&["★"]], + fg: tint(COMET_FG, theme), + }, + has_trunk: false, +}; + +// ─── Shoulder styles ───────────────────────────────────────────────────────── + +const SIDEWALK_BG: Color = Color::Rgb(110, 110, 105); +const SIDEWALK_FG: Color = Color::Rgb(170, 170, 165); +const HARDEDGE_FG: Color = Color::Rgb(180, 180, 180); +const SOFTEDGE_FG: Color = Color::Rgb(110, 110, 110); +const POLE_FG: Color = Color::Rgb(160, 160, 160); +const RAIL_FG: Color = Color::Rgb(150, 130, 110); +const RIVER_FG: Color = Color::Rgb(70, 120, 200); +const PARKED_CAR_FG: Color = Color::Rgb(120, 130, 180); +const COUNTRY_FG: Color = Color::Rgb(90, 80, 60); +const TREE_PINE_DIM: Color = Color::Rgb(25, 105, 25); + +/// Helper: returns the transparent-background cell used when a shoulder is +/// off-row or intentionally empty. +#[inline] +fn blank(fallback_bg: Color) -> Cell { + Cell::new(" ", fallback_bg, fallback_bg) +} + +/// Helper: gate for the `repeat` field — `false` means render blank this row. +#[inline] +fn visible(row: i32, repeat: u8) -> bool { + repeat == 0 || row.rem_euclid(repeat as i32) == 0 +} + +pub const SHOULDER_EMPTY: ShoulderStyle = ShoulderStyle { + cell: |_theme, _row, _repeat, fallback_bg| blank(fallback_bg), +}; +pub const SHOULDER_SIDEWALK: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("▦", tint(SIDEWALK_FG, theme), tint(SIDEWALK_BG, theme)) + }, +}; +pub const SHOULDER_HARD_EDGE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("┃", tint(HARDEDGE_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_SOFT_EDGE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("·", tint(SOFTEDGE_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_PARKED_CAR: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("▣", tint(PARKED_CAR_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_POLES: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("●", tint(POLE_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_RAILROAD: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("╫", tint(RAIL_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_RIVER: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("▚", tint(RIVER_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_COUNTRY_ROAD: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("░", tint(COUNTRY_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_TREE_PINE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("▲", tint(TREE_PINE_DIM, theme), fallback_bg) + }, +}; +pub const SHOULDER_TREE_OAK: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("◉", tint(TREE_OAK_GREEN, theme), fallback_bg) + }, +}; +pub const SHOULDER_TREE_PALM: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("✤", tint(TREE_PALM_GREEN, theme), fallback_bg) + }, +}; + +const GUARDRAIL_FG: Color = Color::Rgb(200, 200, 205); +const BARRIER_FG: Color = Color::Rgb(185, 185, 185); +const WIRE_FENCE_FG: Color = Color::Rgb(140, 128, 108); +const SAND_EDGE_FG: Color = Color::Rgb(205, 180, 100); + +pub const SHOULDER_GUARDRAIL: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("═", tint(GUARDRAIL_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_CRASH_BARRIER: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("█", tint(BARRIER_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_WIRE_FENCE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("┼", tint(WIRE_FENCE_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_SAND_EDGE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("░", tint(SAND_EDGE_FG, theme), fallback_bg) + }, +}; + +const MAGIC_RUNE_FG: Color = Color::Rgb(165, 75, 225); +const BEACON_FG: Color = Color::Rgb(245, 235, 80); +const NEON_BARRIER_FG: Color = Color::Rgb(0, 240, 160); + +pub const SHOULDER_MAGIC_RUNE: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("Ω", tint(MAGIC_RUNE_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_SPACE_BEACON: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + let sym = if row.rem_euclid(6) < 2 { "●" } else { "│" }; + Cell::new(sym, tint(BEACON_FG, theme), fallback_bg) + }, +}; +pub const SHOULDER_NEON_BARRIER: ShoulderStyle = ShoulderStyle { + cell: |theme, row, repeat, fallback_bg| { + if !visible(row, repeat) { + return blank(fallback_bg); + } + Cell::new("▌", tint(NEON_BARRIER_FG, theme), fallback_bg) + }, +}; + +// ─── Obstacle styles ───────────────────────────────────────────────────────── + +const POTHOLE_FG: Color = Color::Rgb(50, 50, 50); +const SPEEDBUMP_FG: Color = Color::Rgb(240, 220, 0); +const SPIKES_FG: Color = Color::Rgb(220, 40, 40); +const FALLEN_TREE_FG: Color = Color::Rgb(220, 40, 40); + +pub const OBSTACLE_POTHOLE_SMALL: ObstacleStyle = ObstacleStyle { + id: 1, + label: "Pothole", + glyphs: (["·", "◉", "·"], POTHOLE_FG), +}; +pub const OBSTACLE_POTHOLE_BIG: ObstacleStyle = ObstacleStyle { + id: 2, + label: "BIG pothole", + glyphs: (["◉", "◉", "◉"], POTHOLE_FG), +}; +pub const OBSTACLE_POTHOLE_CRATER: ObstacleStyle = ObstacleStyle { + id: 3, + label: "CRATER", + glyphs: (["╳", "◉", "╳"], POTHOLE_FG), +}; +pub const OBSTACLE_SPEED_BUMP: ObstacleStyle = ObstacleStyle { + id: 4, + label: "Speed bump", + glyphs: (["▁", "▂", "▁"], SPEEDBUMP_FG), +}; +pub const OBSTACLE_SPIKES: ObstacleStyle = ObstacleStyle { + id: 5, + label: "SPIKES", + glyphs: (["▲", "▲", "▲"], SPIKES_FG), +}; +pub const OBSTACLE_FALLEN_TREE: ObstacleStyle = ObstacleStyle { + id: 6, + label: "Fallen tree", + glyphs: (["≣", "≣", "≣"], FALLEN_TREE_FG), +}; + +const ICE_BLUE: Color = Color::Rgb(175, 210, 240); +const OIL_DARK: Color = Color::Rgb(28, 22, 38); +const SAND_DRIFT_FG: Color = Color::Rgb(220, 190, 108); +const ROADWORK_FG: Color = Color::Rgb(240, 138, 28); +const ANIMAL_FG: Color = Color::Rgb(158, 118, 68); + +pub const OBSTACLE_ICE_PATCH: ObstacleStyle = ObstacleStyle { + id: 7, + label: "Ice patch", + glyphs: (["░", "░", "░"], ICE_BLUE), +}; +pub const OBSTACLE_OIL_SPILL: ObstacleStyle = ObstacleStyle { + id: 8, + label: "Oil spill", + glyphs: (["▒", "▒", "▒"], OIL_DARK), +}; +pub const OBSTACLE_SAND_DRIFT: ObstacleStyle = ObstacleStyle { + id: 9, + label: "Sand drift", + glyphs: (["~", "≈", "~"], SAND_DRIFT_FG), +}; +pub const OBSTACLE_ROADWORK: ObstacleStyle = ObstacleStyle { + id: 10, + label: "Roadwork", + glyphs: (["▲", "▣", "▲"], ROADWORK_FG), +}; +pub const OBSTACLE_ANIMAL: ObstacleStyle = ObstacleStyle { + id: 11, + label: "Animal!", + glyphs: (["(", "A", ")"], ANIMAL_FG), +}; + +const MAGIC_TRAP_FG: Color = Color::Rgb(185, 55, 228); +const DRAGON_FIRE_FG: Color = Color::Rgb(240, 118, 22); +const ASTEROID_FG: Color = Color::Rgb(155, 140, 120); +const METEOR_FG: Color = Color::Rgb(255, 195, 75); + +pub const OBSTACLE_MAGIC_TRAP: ObstacleStyle = ObstacleStyle { + id: 12, + label: "Magic trap!", + glyphs: (["✧", "◇", "✧"], MAGIC_TRAP_FG), +}; +pub const OBSTACLE_DRAGON_FIRE: ObstacleStyle = ObstacleStyle { + id: 13, + label: "Dragon fire!", + glyphs: (["≈", "Λ", "≈"], DRAGON_FIRE_FG), +}; +pub const OBSTACLE_ASTEROID_CHUNK: ObstacleStyle = ObstacleStyle { + id: 14, + label: "Asteroid chunk", + glyphs: (["◢", "◉", "◣"], ASTEROID_FG), +}; +pub const OBSTACLE_METEOR: ObstacleStyle = ObstacleStyle { + id: 15, + label: "METEOR!", + glyphs: (["★", "●", "★"], METEOR_FG), +}; diff --git a/late-ssh/src/app/arcade/traffic/track.rs b/late-ssh/src/app/arcade/traffic/track.rs new file mode 100644 index 00000000..fab286f4 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/track.rs @@ -0,0 +1,418 @@ +//! Track data model for the Traffic game. +//! +//! A **track** is a named, multi-stage course. Each [`Stage`] defines the road +//! geometry, traffic, scenery, and theme that apply while the player is in that +//! stage. Stages succeed each other when the player covers their `distance` +//! budget; the next stage can have completely different lane counts, themes, +//! sceneries, and dividers. +//! +//! Tracks are hard-coded as `&'static` Rust data (no file I/O), so authoring a +//! new one is a code change. See `tracks/sample.rs` for an end-to-end example +//! and `tracks/presets.rs` for reusable building blocks. +//! +//! # Extensibility +//! +//! Aspects (lane surfaces, dividers, scenery, shoulders, obstacles, objects) are +//! **open** — each is a small struct holding fn-pointer(s) for rendering. The +//! standard library lives in `theme.rs` as `pub const` instances (e.g. +//! `theme::LANE_ASPHALT_PREMIUM`). A new track can define its own instances +//! inline; no changes to `track.rs` or `theme.rs` are needed. +//! +//! [`Theme`] remains a closed enum for now. Extending it means adding a variant +//! here and a `tint` branch in `theme.rs`. +//! +//! # Coordinate units +//! +//! All distance/speed values are in **displayed** units (the numbers the player +//! sees). [`Track::distance_scale`] and [`Track::speed_scale`] convert between +//! these and the internal physics units used by `traffic::state`. + +#![allow(dead_code)] + +use ratatui::style::Color; + +// ─── Rendering primitives (used by style descriptors) ─────────────────────── + +/// One rendered terminal cell: glyph + foreground + background. +#[derive(Clone, Copy, Debug)] +pub struct Cell { + pub sym: &'static str, + pub fg: Color, + pub bg: Color, +} + +impl Cell { + pub const fn new(sym: &'static str, fg: Color, bg: Color) -> Self { + Self { sym, fg, bg } + } +} + +/// A multi-row scenery sprite anchored at its bottom-left cell; extends +/// `glyphs.len() - 1` rows upward. Each row is a slice of single-cell strings, +/// one per column (`len == width`). +#[derive(Clone, Copy, Debug)] +pub struct Sprite { + pub width: u8, + /// Rows ordered top-to-bottom; `glyphs[0]` is the topmost row. + pub glyphs: &'static [&'static [&'static str]], + pub fg: Color, +} + +// ─── Style descriptor types ───────────────────────────────────────────────── +// +// Each "aspect" is now a small struct with fn-pointer field(s) rather than an +// enum variant. This lets track authors define custom looks inline without +// touching any shared file. All standard instances live in `theme.rs`. + +/// Rendering descriptor for a lane surface. +#[derive(Clone, Copy, Debug)] +pub struct LaneStyle { + pub cell: fn(theme: Theme, row: i32, col: u16) -> Cell, + pub bg: fn(theme: Theme) -> Color, +} + +/// Rendering descriptor for a lane divider column. Theme-independent. +#[derive(Clone, Copy, Debug)] +pub struct DividerStyle { + pub cell: fn(row: i32, bg: Color) -> Cell, +} + +/// Rendering descriptor for a scenery background fill. +#[derive(Clone, Copy, Debug)] +pub struct SceneryStyle { + pub bg: fn(theme: Theme) -> Color, +} + +/// Rendering and identity descriptor for a stationary obstacle. +/// +/// `id` **must** be unique among all `ObstacleStyle` instances that can appear +/// in the same track — it seeds the deterministic placement hash. Standard +/// library IDs are 1–6 (`theme::OBSTACLE_*`). Custom obstacles should start +/// from 100 or higher to avoid collisions. +#[derive(Clone, Copy, Debug)] +pub struct ObstacleStyle { + /// Stable integer used in the placement hash. Never change once deployed. + pub id: i64, + /// Short label shown in the right-panel effects log on crossing. + pub label: &'static str, + /// Per-column glyphs (3 columns = car body width) + foreground colour. + /// Theme-independent. + pub glyphs: ([&'static str; 3], Color), +} + +/// Rendering descriptor for a scenery object (tree, building, etc.). +#[derive(Clone, Copy, Debug)] +pub struct ObjectStyle { + pub sprite: fn(theme: Theme) -> Sprite, + /// When true, the bottom row of the sprite renders with `theme::trunk_color` + /// instead of `sprite.fg` to visually separate trunk from canopy. + pub has_trunk: bool, +} + +/// Rendering descriptor for one shoulder column. +#[derive(Clone, Copy, Debug)] +pub struct ShoulderStyle { + /// `repeat` is forwarded from [`Shoulder::repeat`]; the fn is responsible + /// for honouring it (blank on off-rows) and for the empty/transparent case. + pub cell: fn(theme: Theme, row: i32, repeat: u8, fallback_bg: Color) -> Cell, +} + +// ─── Track / Stage ────────────────────────────────────────────────────────── + +/// A complete drivable course composed of one or more [`Stage`]s. +#[derive(Debug, Clone, Copy)] +pub struct Track { + /// Title shown in the picker and above the road. + pub name: &'static str, + pub author: &'static str, + /// Long-form description shown in the picker only. + pub description: &'static str, + /// Ordered stages. Must contain at least one entry. + pub stages: &'static [Stage], + /// Multiplies displayed distance vs. physics distance. + pub distance_scale: f32, + /// Multiplies the displayed speedometer reading vs. physics speed. + pub speed_scale: f32, + /// Lives before the run ends. + pub lives: u8, +} + +/// A contiguous segment of a [`Track`] with its own road, theme, and scenery. +#[derive(Debug, Clone, Copy)] +pub struct Stage { + pub name: &'static str, + /// Short description shown in the bottom-right of the HUD during play. + pub description: &'static str, + /// Environmental icon glyph shown beside the track name (e.g. `"🏙"`). + /// Use any single-width emoji or ASCII character. + pub icon: &'static str, + pub theme: Theme, + /// Displayed kilometres the player must travel before the next stage. + pub distance_km: f32, + pub road: Road, +} + +// ─── Theme ────────────────────────────────────────────────────────────────── + +/// Visual theme applied across all themed rendering calls for a stage. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Theme { + Standard, + Winter, + Desert, +} + +impl Theme { + pub fn name(self) -> &'static str { + match self { + Theme::Standard => "Normal", + Theme::Winter => "Winter", + Theme::Desert => "Desert", + } + } + + pub fn icon(self) -> &'static str { + match self { + Theme::Standard => "☀", + Theme::Winter => "❄", + Theme::Desert => "🌵", + } + } +} + +// ─── Road ─────────────────────────────────────────────────────────────────── + +/// Geometry, traffic, and scenery for a single stage. +#[derive(Debug, Clone, Copy)] +pub struct Road { + pub aspect: RoadAspect, + pub lanes: Lanes, + pub sceneries: Sceneries, + pub shoulders: Shoulders, +} + +/// Road-wide aspects that don't apply per-lane. +#[derive(Debug, Clone, Copy)] +pub struct RoadAspect { + pub dividers: Divider, +} + +/// Divider styling between groups (primary) and within a group (lane). +#[derive(Debug, Clone, Copy)] +pub struct Divider { + /// Between the two traffic directions (incoming vs. outgoing). + pub primary: DividerStyle, + /// Between two lanes in the same direction. + pub lane: DividerStyle, +} + +// ─── Lanes ────────────────────────────────────────────────────────────────── + +/// Lane configuration for both traffic directions. +/// +/// Both slices are ordered **inward → outward** (index 0 = closest to the +/// center divider, last = outermost/shoulder lane). A symmetric definition +/// produces a symmetric road: `incoming: &[FAST, SLOW]` mirrors +/// `outgoing: &[FAST, SLOW]` with the fast lanes adjacent to the center. +/// +/// **Invariants:** at least one `outgoing` lane; at least 2 lanes total. +#[derive(Debug, Clone, Copy)] +pub struct Lanes { + pub incoming: &'static [Lane], + pub outgoing: &'static [Lane], +} + +/// One lane: surface, speed bounds, traffic, and obstacles. +#[derive(Debug, Clone, Copy)] +pub struct Lane { + pub style: LaneStyle, + /// Minimum displayed km/h the player may drive on this lane. + pub own_min_speed: f32, + /// Maximum displayed km/h the player may drive on this lane. + pub own_max_speed: f32, + /// Passive deceleration (displayed km/h per second) when not accelerating. + pub passive_decel: f32, + pub traffic_min_speed: f32, + pub traffic_max_speed: f32, + /// Traffic density `[0.0, 1.0]`. `0.0` = empty; `1.0` = one car every + /// `AI_MIN_SEPARATION_M` across the full spawn horizon (maximum packing + /// without overlap). Values above `1.0` are clamped internally. + pub traffic_density: f32, + pub traffic_cars: &'static [Car], + pub obstacles: &'static [Obstacle], +} + +/// AI car body shape. Width is always 3 chars; `height` is in rows. +#[derive(Debug, Clone, Copy)] +pub struct Car { + pub height: u8, + /// Relative weight at spawn-time shape selection. Higher = more common. + pub incidence: f32, +} + +// ─── Obstacles ────────────────────────────────────────────────────────────── + +/// Stationary hazard sprinkled along a lane. +#[derive(Debug, Clone, Copy)] +pub struct Obstacle { + pub style: ObstacleStyle, + /// Average fraction of lane-length occupied by this obstacle type `[0, 1]`. + pub frequency: f32, + /// Effects that fire once when the player drives over the obstacle. + pub effects: &'static [ObstacleEffect], +} + +impl Obstacle { + pub fn has_crash(&self) -> bool { + self.effects.contains(&ObstacleEffect::Crash) + } +} + +/// One-shot effect triggered when the player crosses an obstacle. +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ObstacleEffect { + /// Instant game-over. + Crash, + /// Multiplicative speed change. `-1.0` = full stop; `+0.5` = +50%. + SpeedChange { affect: f32 }, + /// Player can't accelerate for `cooldown_ms` milliseconds. + BlockGas { cooldown_ms: u16 }, + /// Player can't brake for `cooldown_ms` milliseconds. + BlockBrakes { cooldown_ms: u16 }, + /// Player can't change lanes for `cooldown_ms` milliseconds. + BlockWheels { cooldown_ms: u16 }, +} + +// ─── Scenery ──────────────────────────────────────────────────────────────── + +/// Left and right scenery slabs flanking the road. +/// +/// "Left" / "right" are absolute screen sides. +#[derive(Debug, Clone, Copy)] +pub struct Sceneries { + pub left: Scenery, + pub right: Scenery, +} + +/// One side's scenery: a band of fixed character width with a background and +/// randomly-placed objects on top. +#[derive(Debug, Clone, Copy)] +pub struct Scenery { + pub width: u8, + pub background: SceneryStyle, + /// Objects to scatter. Only spawn in cells not covered by a shoulder. + pub objects: &'static [Object], +} + +/// One scattered scenery object. +#[derive(Debug, Clone, Copy)] +pub struct Object { + pub style: ObjectStyle, + /// Relative weight when picking which object to render at a given cell. + pub incidence: f32, +} + +// ─── Shoulders ────────────────────────────────────────────────────────────── + +/// Shoulder strips on each side of the road, drawn on top of scenery. +#[derive(Debug, Clone, Copy)] +pub struct Shoulders { + /// Listed from the road edge outward. + pub left: &'static [Shoulder], + pub right: &'static [Shoulder], +} + +/// One shoulder column. +#[derive(Debug, Clone, Copy)] +pub struct Shoulder { + pub style: ShoulderStyle, + /// Repetition period in rows: `0` = continuous; `n > 0` = every `n` rows. + pub repeat: u8, +} + +// ─── Helpers ──────────────────────────────────────────────────────────────── + +impl Track { + /// Highest normalized finish score any track can award. + pub const SCORE_MAX: i64 = 1000; + + pub fn total_distance_km(&self) -> f32 { + self.stages.iter().map(|s| s.distance_km).sum() + } + + /// Theoretical `(fastest, slowest)` completion times in seconds, derived + /// purely from the track definition: per stage, driving at the highest + /// (fastest) or lowest (slowest) lane speed the whole way. These bounds are + /// what make every track's finish score comparable regardless of its + /// distance or speed definition. + pub fn theoretical_times_s(&self) -> (f32, f32) { + let mut fastest = 0.0f32; + let mut slowest = 0.0f32; + for stage in self.stages { + let dist_m = stage.distance_km * 1000.0 * self.distance_scale; + let total = stage.road.lanes.total(); + let mut lane_max = f32::MIN; + let mut lane_min = f32::MAX; + for i in 0..total { + if let Some(lane) = stage.road.lanes.get(i) { + lane_max = lane_max.max(lane.own_max_speed); + lane_min = lane_min.min(lane.own_min_speed); + } + } + if !lane_max.is_finite() || lane_max <= 0.0 { + continue; + } + if !lane_min.is_finite() || lane_min <= 0.0 { + lane_min = lane_max; + } + let v_max = lane_max / 3.6 * self.speed_scale; + let v_min = lane_min / 3.6 * self.speed_scale; + fastest += dist_m / v_max; + slowest += dist_m / v_min; + } + (fastest, slowest) + } + + /// Grade a completion time to `0..=SCORE_MAX`. Finishing at the theoretical + /// max speed scores `SCORE_MAX`; finishing at the min speed scores `0`. + pub fn grade_time(&self, elapsed_s: f32) -> i64 { + let (fastest, slowest) = self.theoretical_times_s(); + if slowest <= fastest { + return Self::SCORE_MAX; + } + let frac = (slowest - elapsed_s) / (slowest - fastest); + (frac.clamp(0.0, 1.0) * Self::SCORE_MAX as f32).round() as i64 + } +} + +impl Lanes { + pub fn total(&self) -> usize { + self.incoming.len() + self.outgoing.len() + } + + /// Look up a lane by flat index. + /// + /// Flat layout (left→right on screen): + /// `incoming[last] … incoming[0] ║ outgoing[0] … outgoing[last]` + /// + /// Incoming lanes are indexed **inward-out** in the slice (index 0 = closest to + /// center divider) but rendered outermost-first, so the slice order is reversed + /// here. Outgoing lanes render left-to-right matching the slice order. + pub fn get(&self, flat_idx: usize) -> Option<&Lane> { + let in_n = self.incoming.len(); + if flat_idx < in_n { + Some(&self.incoming[in_n - 1 - flat_idx]) + } else { + self.outgoing.get(flat_idx - in_n) + } + } + + /// Flat index of the first outgoing lane (the player's default start lane). + pub fn player_start_idx(&self) -> usize { + self.incoming.len() + } + + pub fn is_incoming(&self, flat_idx: usize) -> bool { + flat_idx < self.incoming.len() + } +} diff --git a/late-ssh/src/app/arcade/traffic/tracks/batin.rs b/late-ssh/src/app/arcade/traffic/tracks/batin.rs new file mode 100644 index 00000000..a78f0793 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/batin.rs @@ -0,0 +1,1313 @@ +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Shoulder, Shoulders, Stage, + Theme, Track, +}; + +// PRESETS + +const B_CITY_LANE: Lane = Lane { + own_max_speed: 120.0, + obstacles: &[Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }], + ..CITY_LANE +}; + +const B_CITY_LANE_OUT: Lane = Lane { + traffic_density: 0.4, + ..B_CITY_LANE +}; + +const B_CITY_LANE_IN: Lane = Lane { + own_max_speed: 150.0, + traffic_density: 0.2, + ..B_CITY_LANE +}; + +const B_TOWN_LANE: Lane = Lane { + own_max_speed: 100.0, + traffic_max_speed: 80.0, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.01, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.01, + effects: &[ObstacleEffect::SpeedChange { affect: -0.8 }], + }, + ], + ..CITY_LANE +}; + +const B_TOWN_LANE_OUT: Lane = Lane { + traffic_density: 0.6, + ..B_TOWN_LANE +}; + +const B_TOWN_LANE_IN: Lane = Lane { + own_max_speed: 140.0, + traffic_density: 0.4, + ..B_TOWN_LANE +}; + +const B_OUTSKIRT_LANE: Lane = Lane { + own_max_speed: 100.0, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.03, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }, + ], + ..CITY_LANE +}; + +const B_OUTSKIRT_LANE_OUT: Lane = Lane { + traffic_density: 0.3, + ..B_OUTSKIRT_LANE +}; + +const B_OUTSKIRT_LANE_IN: Lane = Lane { + own_max_speed: 120.0, + traffic_density: 0.15, + ..B_OUTSKIRT_LANE +}; + +const B_OUTSIDE_LANE: Lane = Lane { + own_max_speed: 150.0, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.005, + effects: &[ObstacleEffect::SpeedChange { affect: -0.9 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + ], + ..CITY_LANE +}; + +const B_OUTSIDE_LANE_OUT: Lane = Lane { + traffic_density: 0.35, + ..B_OUTSIDE_LANE +}; + +const B_OUTSIDE_LANE_OUT_SLOW: Lane = Lane { + traffic_density: 0.15, + own_max_speed: 100.0, + traffic_max_speed: 60.0, + style: theme::LANE_ASPHALT_PATCHY, + ..B_OUTSIDE_LANE +}; + +const B_OUTSIDE_LANE_IN: Lane = Lane { + own_max_speed: 200.0, + traffic_max_speed: 110.0, + traffic_density: 0.15, + ..B_OUTSIDE_LANE +}; + +const B_OUTSIDE_LANE_IN_SLOW: Lane = Lane { + own_max_speed: 140.0, + traffic_max_speed: 100.0, + traffic_density: 0.15, + style: theme::LANE_ASPHALT_PATCHY, + ..B_OUTSIDE_LANE +}; + +const B_RURAL_LANE: Lane = Lane { + style: theme::LANE_GRAVEL, + own_min_speed: 20.0, + own_max_speed: 100.0, + passive_decel: 0.0, + traffic_min_speed: 30.0, + traffic_max_speed: 60.0, + traffic_density: 0.1, + traffic_cars: RURAL_CAR_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.01, + effects: &[ObstacleEffect::SpeedChange { affect: -0.9 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.05, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.1, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }, + ], +}; + +const B_RURAL_LANE_OUT: Lane = Lane { + traffic_density: 0.2, + ..B_RURAL_LANE +}; + +const B_RURAL_LANE_OUT_SLOW: Lane = Lane { + style: theme::LANE_DIRT, + traffic_min_speed: 20.0, + traffic_max_speed: 50.0, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.9 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.1, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.3, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }, + ], + ..B_RURAL_LANE_OUT +}; + +const B_RURAL_LANE_IN: Lane = Lane { + own_max_speed: 120.0, + traffic_max_speed: 50.0, + traffic_density: 0.1, + ..B_RURAL_LANE +}; + +const B_RURAL_LANE_IN_SLOW: Lane = Lane { + style: theme::LANE_DIRT, + own_max_speed: 80.0, + traffic_min_speed: 15.0, + traffic_max_speed: 40.0, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.9 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.1, + effects: &[ObstacleEffect::SpeedChange { affect: -0.6 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.3, + effects: &[ObstacleEffect::SpeedChange { affect: -0.3 }], + }, + ], + ..B_RURAL_LANE_IN +}; + +// STAGES + +const S01_CLUJ: Stage = Stage { + name: "Cluj-Napoca", + description: "My city of birth. Year 1998. Got our Dacia 1310 ready to go - washed and filled up. Car is full of bags of things we'll mostly bring back intact. 3 kids in the car. Let's go.", + icon: theme::STAGE_CITY, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_CITY_LANE_OUT, B_CITY_LANE_OUT], + incoming: &[B_CITY_LANE_IN, B_CITY_LANE_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S02_SANNICOARA: Stage = Stage { + name: "Sannicoara", + description: "Moving out of the city.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSKIRT_LANE_OUT], + incoming: &[B_OUTSKIRT_LANE_IN, B_OUTSKIRT_LANE_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_POLES, + repeat: 20, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S03_APAHIDA: Stage = Stage { + name: "Apahida", + description: "This is a town close to the main city (Cluj). I live here now :)", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 3.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSKIRT_LANE_OUT], + incoming: &[B_OUTSKIRT_LANE_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_OAK, + repeat: 1, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S04_JUCU: Stage = Stage { + name: "Jucu", + description: "Jucu is actually a group of 3 villages. We're moving alongside all of them, not really through.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 4.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ + B_OUTSIDE_LANE_OUT, + B_OUTSIDE_LANE_OUT, + B_OUTSIDE_LANE_OUT_SLOW, + ], + incoming: &[B_OUTSIDE_LANE_IN, B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S05_E576_1: Stage = Stage { + name: "E576/1", + description: "Rural area begins.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 7.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT, B_OUTSIDE_LANE_OUT_SLOW], + incoming: &[B_OUTSIDE_LANE_IN, B_OUTSIDE_LANE_IN_SLOW], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S06_RASCRUCI: Stage = Stage { + name: "Rascruci", + description: "Boring village.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S07_BONTIDA: Stage = Stage { + name: "Bontida", + description: "Another boring village.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S08_FUNDATURA: Stage = Stage { + name: "Fundatura", + description: "Yet another boring village.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S09_ICLOD: Stage = Stage { + name: "Iclod", + description: "Boring village, again...", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT, B_OUTSIDE_LANE_OUT_SLOW], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: VILLAGE_SCENERY, + right: VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S10_LIVADA: Stage = Stage { + name: "Livada", + description: "Aaaand... it's another boring village.", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT, B_OUTSIDE_LANE_OUT_SLOW], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S11_BAITA: Stage = Stage { + name: "Baita", + description: "Coming up to something else...", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S12_GHERLA: Stage = Stage { + name: "Gherla", + description: "Hey, a town! It is pretty nice. There's also a prison nearby.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 5.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_TOWN_LANE_OUT, B_TOWN_LANE_OUT], + incoming: &[B_TOWN_LANE_IN, B_TOWN_LANE_IN], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: TOWN_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_PARKED_CAR, + repeat: 4, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S13_E576_2: Stage = Stage { + name: "E576/2", + description: "Road in the middle of nowhere. Getting close to a larger town, then it's all countryside.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_OAK, + repeat: 3, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S14_DEJ: Stage = Stage { + name: "Dej", + description: "Quickly touching this larger town before heading deep into countryside. Crossing the railroad here.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 3.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSKIRT_LANE_OUT, B_OUTSKIRT_LANE_OUT], + incoming: &[B_OUTSKIRT_LANE_IN, B_OUTSKIRT_LANE_IN], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_OAK, + repeat: 3, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_OAK, + repeat: 3, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RAILROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S15_MANASTIREA: Stage = Stage { + name: "Manastirea", + description: "Wild countryside begins.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT], + incoming: &[B_OUTSIDE_LANE_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S16_DJ161D_1: Stage = Stage { + name: "DJ161D/1", + description: "Wild countryside continues. Next village in 7km", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 7.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT_SLOW], + incoming: &[B_OUTSIDE_LANE_IN_SLOW], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S17_NIRES: Stage = Stage { + name: "Nires", + description: "A large-ish village close to our destination.", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_RURAL_LANE_OUT], + incoming: &[B_RURAL_LANE_IN], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S18_DJ161D_2: Stage = Stage { + name: "DJ161D/2", + description: "Really getting rugged now. Next village is a long boring one, in 5km.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 5.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_OUTSIDE_LANE_OUT_SLOW], + incoming: &[B_OUTSIDE_LANE_IN_SLOW], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: NO_SHOULDERS, + }, +}; + +const S19_UNGURAS: Stage = Stage { + name: "Unguras", + description: "A long village. Last one before we get there.", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 4.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_RURAL_LANE_OUT], + incoming: &[B_RURAL_LANE_IN], + }, + sceneries: Sceneries { + left: VILLAGE_SCENERY, + right: VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, +}; + +const S20_DJ161D_3: Stage = Stage { + name: "DJ161D/3", + description: "Final stretch before destination. I usually was very carsick by this point :D.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 3.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_RURAL_LANE_OUT_SLOW], + incoming: &[B_RURAL_LANE_IN_SLOW], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: NO_SHOULDERS, + }, +}; + +const S21_BATIN: Stage = Stage { + name: "Batin", + description: "We're here. Can't wait to play with the cat.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 2.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[B_RURAL_LANE_OUT_SLOW], + incoming: &[B_RURAL_LANE_IN_SLOW], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: VILLAGE_SCENERY, + }, + shoulders: NO_SHOULDERS, + }, +}; + +// TRACK + +pub const TRACK: Track = Track { + name: "Batin", + author: "odd", + description: "A road trip I used to make when I was a kid. From the city to our countryside house. Starts decently and ends with extremely rugged roads.", + stages: &[ + S01_CLUJ, + S02_SANNICOARA, + S03_APAHIDA, + S04_JUCU, + S05_E576_1, + S06_RASCRUCI, + S07_BONTIDA, + S08_FUNDATURA, + S09_ICLOD, + S10_LIVADA, + S11_BAITA, + S12_GHERLA, + S13_E576_2, + S14_DEJ, + S15_MANASTIREA, + S16_DJ161D_1, + S17_NIRES, + S18_DJ161D_2, + S19_UNGURAS, + S20_DJ161D_3, + S21_BATIN, + ], + distance_scale: 0.5, + speed_scale: 2.0, + lives: 1, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/crazy.rs b/late-ssh/src/app/arcade/traffic/tracks/crazy.rs new file mode 100644 index 00000000..57737d10 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/crazy.rs @@ -0,0 +1,368 @@ +//! Chaos Highway — the track with no rules. +//! +//! Stages: 5+4 lane Tokyo mega-interchange → 600 km/h neon turbo strip → +//! monster truck alley → gridlock hell → the obstacle garden → +//! micro car cobblestone city → 6+5 lane impossible highway → +//! 1000 km/h final sprint. ~69 km, ~9 min. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Car, Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Scenery, Shoulders, + Stage, Theme, Track, +}; + +// ─── Local car mixes ───────────────────────────────────────────────────────── + +const MONSTER_MIX: &[Car] = &[CAR_SEMI, CAR_TRUCK, CAR_RV, CAR_MONSTER]; +const MICRO_MIX: &[Car] = &[CAR_MICRO, CAR_HATCHBACK]; + +// ─── Lane variants ──────────────────────────────────────────────────────────── + +// Tokyo: dense city at speed +const TOKYO_OUT: Lane = Lane { + own_max_speed: 110.0, + traffic_density: 0.4, + traffic_cars: CRAZY_MIX, + ..CITY_LANE +}; +const TOKYO_IN: Lane = Lane { + own_max_speed: 110.0, + traffic_density: 0.35, + traffic_cars: CRAZY_MIX, + ..CITY_LANE +}; + +// Gridlock: legally a road, practically a car park +const GRIDLOCK: Lane = Lane { + traffic_density: 0.8, + ..GRIDLOCK_LANE +}; + +// Monster Truck Alley: wide lanes, everything oversized +const MONSTER_LANE: Lane = Lane { + own_max_speed: 130.0, + traffic_density: 0.35, + traffic_cars: MONSTER_MIX, + ..HIGHWAY_LANE +}; + +// Obstacle Garden: every hazard type at maximum density +const OBSTACLE_GARDEN_LANE: Lane = Lane { + own_max_speed: 130.0, + traffic_density: 0.22, + traffic_cars: CRAZY_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.042, + effects: &[ObstacleEffect::SpeedChange { affect: -0.40 }], + }, + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.032, + effects: &[ObstacleEffect::SpeedChange { affect: -0.65 }], + }, + Obstacle { + style: theme::OBSTACLE_OIL_SPILL, + frequency: 0.028, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 400 }, + ObstacleEffect::SpeedChange { affect: -0.20 }, + ], + }, + Obstacle { + style: theme::OBSTACLE_FALLEN_TREE, + frequency: 0.014, + effects: &[ObstacleEffect::SpeedChange { affect: -0.90 }], + }, + Obstacle { + style: theme::OBSTACLE_SPIKES, + frequency: 0.010, + effects: &[ObstacleEffect::Crash], + }, + ], + ..PLAINS_LANE +}; + +// Micro Lane: cobblestones, bumper cars edition +const MICRO_LANE: Lane = Lane { + own_max_speed: 32.0, + traffic_density: 0.5, + traffic_cars: MICRO_MIX, + ..COBBLE_LANE +}; + +// Impossible Highway: turbo + obstacles + chaos traffic +const IMPOSSIBLE_LANE: Lane = Lane { + own_max_speed: 500.0, + traffic_density: 0.30, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.022, + effects: &[ObstacleEffect::SpeedChange { affect: -0.60 }], + }, + Obstacle { + style: theme::OBSTACLE_OIL_SPILL, + frequency: 0.016, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 300 }, + ObstacleEffect::SpeedChange { affect: -0.15 }, + ], + }, + ], + ..TURBO_LANE +}; + +// Final Sprint: 1000 km/h. Empty road. Go. +const SPRINT_LANE: Lane = Lane { + own_max_speed: 1000.0, + traffic_density: 0.01, + obstacles: &[], + ..TURBO_LANE +}; + +// ─── Local sceneries ───────────────────────────────────────────────────────── + +const NEON_VOID: Scenery = Scenery { + width: 6, + background: theme::SCENERY_VOID, + objects: &[], +}; + +// ─── Stages ────────────────────────────────────────────────────────────────── + +const S01_TOKYO: Stage = Stage { + name: "Tokyo Interchange", + description: "Nine lanes. Everyone going everywhere at once. The world's most complex road junction in rush hour. Somehow still moving.", + icon: theme::STAGE_METROPOLIS, + theme: Theme::Standard, + distance_km: 10.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[TOKYO_OUT, TOKYO_OUT, TOKYO_OUT, TOKYO_OUT, TOKYO_OUT], + incoming: &[TOKYO_IN, TOKYO_IN, TOKYO_IN, TOKYO_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S02_TURBO_STRIP: Stage = Stage { + name: "Turbo Strip", + description: "Private raceway. No limit. No traffic. Just asphalt, neon barriers, and 600 km/h of pure velocity. This is what cars are for.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 15.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[TURBO_LANE, TURBO_LANE, TURBO_LANE], + incoming: &[TURBO_LANE, TURBO_LANE], + }, + sceneries: Sceneries { + left: NEON_VOID, + right: NEON_VOID, + }, + shoulders: Shoulders { + left: NEON_BARRIER_SHOULDERS, + right: NEON_BARRIER_SHOULDERS, + }, + }, +}; + +const S03_MONSTER_ALLEY: Stage = Stage { + name: "Monster Truck Alley", + description: "Every vehicle is enormous. Semis, monster trucks, and 18-wheelers wall to wall. Weave between rolling cliffs of steel at moderate speed.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MONSTER_LANE, MONSTER_LANE], + incoming: &[MONSTER_LANE, MONSTER_LANE], + }, + sceneries: Sceneries { + left: PLAINS_SCENERY, + right: PLAINS_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: HIGHWAY_SHOULDERS, + }, + }, +}; + +const S04_GRIDLOCK: Stage = Stage { + name: "Gridlock City", + description: "Monday morning. Every road. Zero movement. Maximum density. Welcome to the commute — everybody stop, nobody go, all horns optional.", + icon: theme::STAGE_CHAOS, + theme: Theme::Standard, + distance_km: 3.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[GRIDLOCK], + incoming: &[GRIDLOCK], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: TOWN_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S05_OBSTACLE_GARDEN: Stage = Stage { + name: "The Obstacle Garden", + description: "Road designers were fired after this. Potholes, speed bumps, oil slicks, fallen trees, and spike strips — all on the same road, at high density.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[OBSTACLE_GARDEN_LANE, OBSTACLE_GARDEN_LANE], + incoming: &[OBSTACLE_GARDEN_LANE, OBSTACLE_GARDEN_LANE], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: GUARDRAIL_SHOULDERS, + right: GUARDRAIL_SHOULDERS, + }, + }, +}; + +const S06_MICRO_CITY: Stage = Stage { + name: "Micro City", + description: "Tiny cars. Maximum density. Cobblestones. 30 km/h speed limit strictly enforced by physics. Like bumper cars, but everyone is actually trying.", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 5.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MICRO_LANE], + incoming: &[MICRO_LANE], + }, + sceneries: Sceneries { + left: EURO_VILLAGE_SCENERY, + right: EURO_VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S07_IMPOSSIBLE_HIGHWAY: Stage = Stage { + name: "Impossible Highway", + description: "Eleven lanes. Mixed traffic from microcars to monster trucks. 500 km/h limit. Speed bumps and oil slicks for no reason. Perfectly normal road.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 12.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + ], + incoming: &[ + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + IMPOSSIBLE_LANE, + ], + }, + sceneries: Sceneries { + left: STARFIELD_SCENERY, + right: STARFIELD_SCENERY, + }, + shoulders: Shoulders { + left: NEON_BARRIER_SHOULDERS, + right: NEON_BARRIER_SHOULDERS, + }, + }, +}; + +const S08_FINAL_SPRINT: Stage = Stage { + name: "Final Sprint", + description: "One lane. No traffic. No obstacles. One thousand kilometres per hour. Eight kilometres. Seven seconds. Go.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[SPRINT_LANE], + incoming: &[SPRINT_LANE], + }, + sceneries: Sceneries { + left: NEON_VOID, + right: NEON_VOID, + }, + shoulders: Shoulders { + left: CRASH_BARRIER_SHOULDERS, + right: CRASH_BARRIER_SHOULDERS, + }, + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "Chaos Highway", + author: "claude", + description: "A road with no rules and every extreme: 9-lane Tokyo interchange, 600 km/h neon strip, wall-to-wall monster trucks, rush-hour gridlock, the Obstacle Garden, micro-car bumper city, 11-lane impossible highway, and a 1000 km/h final sprint.", + stages: &[ + S01_TOKYO, + S02_TURBO_STRIP, + S03_MONSTER_ALLEY, + S04_GRIDLOCK, + S05_OBSTACLE_GARDEN, + S06_MICRO_CITY, + S07_IMPOSSIBLE_HIGHWAY, + S08_FINAL_SPRINT, + ], + distance_scale: 0.80, + speed_scale: 3.0, + lives: 5, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/eurotrip.rs b/late-ssh/src/app/arcade/traffic/tracks/eurotrip.rs new file mode 100644 index 00000000..81ba9819 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/eurotrip.rs @@ -0,0 +1,575 @@ +//! Eurotrip — London to Barcelona. +//! +//! Across the English Channel, through France, Belgium, the German Autobahn +//! (no speed limit!), Austrian motorways, the Swiss/Austrian Alps, northern +//! Italy, the French Riviera, Monaco's cobblestones, and into Barcelona. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Shoulder, Shoulders, Stage, + Theme, Track, +}; + +// ─── Lane variants ─────────────────────────────────────────────────────────── + +const LONDON_OUT: Lane = Lane { + traffic_density: 0.35, + traffic_cars: EURO_CITY_MIX, + ..CITY_LANE +}; +const LONDON_IN: Lane = Lane { + traffic_density: 0.30, + traffic_cars: EURO_CITY_MIX, + ..CITY_LANE +}; + +const EURO_CITY_OUT: Lane = Lane { + traffic_density: 0.4, + traffic_cars: EURO_CITY_MIX, + ..CITY_LANE +}; +const EURO_CITY_IN: Lane = Lane { + traffic_density: 0.3, + traffic_cars: EURO_CITY_MIX, + ..CITY_LANE +}; + +const MOTORWAY_OUT: Lane = Lane { + traffic_density: 0.2, + ..MOTORWAY_LANE +}; +const MOTORWAY_IN: Lane = Lane { + own_max_speed: 230.0, // wrong-side speed bonus: 200 → 230 + traffic_density: 0.15, + ..MOTORWAY_LANE +}; + +// Channel Tunnel: controlled, slower, enclosed +const TUNNEL_LANE: Lane = Lane { + own_max_speed: 140.0, + traffic_min_speed: 80.0, + traffic_max_speed: 120.0, + traffic_density: 0.1, + traffic_cars: EURO_HIGHWAY_MIX, + obstacles: &[], + ..MOTORWAY_LANE +}; + +// German Autobahn: no speed limit on unrestricted sections +const AUTOBAHN_FAST_OUT: Lane = Lane { + own_min_speed: 0.0, + own_max_speed: 350.0, + traffic_min_speed: 100.0, + traffic_max_speed: 220.0, + traffic_density: 0.12, + traffic_cars: EURO_HIGHWAY_MIX, + obstacles: &[], + ..AUTOBAHN_LANE +}; +const AUTOBAHN_SLOW_IN: Lane = Lane { + own_max_speed: 200.0, + traffic_min_speed: 80.0, + traffic_max_speed: 150.0, + traffic_density: 0.20, + ..AUTOBAHN_FAST_OUT +}; + +// Austrian/Italian motorway: slightly slower, more trucks +const ALPINE_MOTORWAY_OUT: Lane = Lane { + traffic_density: 0.18, + traffic_cars: EURO_HIGHWAY_MIX, + ..MOTORWAY_LANE +}; +const ALPINE_MOTORWAY_IN: Lane = Lane { + own_max_speed: 225.0, // wrong-side speed bonus: 200 → 225 + traffic_density: 0.14, + ..ALPINE_MOTORWAY_OUT +}; + +// Mountain pass: single narrow lane, guardrails, ice risk +const ALPS_LANE: Lane = Lane { + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_ICE_PATCH, + frequency: 0.030, + effects: &[ObstacleEffect::BlockBrakes { cooldown_ms: 650 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.020, + effects: &[ObstacleEffect::SpeedChange { affect: -0.25 }], + }, + Obstacle { + style: theme::OBSTACLE_FALLEN_TREE, + frequency: 0.005, + effects: &[ObstacleEffect::Crash], + }, + ], + ..MOUNTAIN_LANE +}; + +// Brenner Pass descent: road works common, ice possible +const BRENNER_LANE: Lane = Lane { + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_ROADWORK, + frequency: 0.020, + effects: &[ + ObstacleEffect::SpeedChange { affect: -0.55 }, + ObstacleEffect::BlockGas { cooldown_ms: 700 }, + ], + }, + Obstacle { + style: theme::OBSTACLE_ICE_PATCH, + frequency: 0.015, + effects: &[ObstacleEffect::BlockBrakes { cooldown_ms: 500 }], + }, + ], + ..MOUNTAIN_LANE +}; + +// Monaco cobblestones: very tight, dense luxury traffic +const MONACO_LANE: Lane = Lane { + own_max_speed: 55.0, + traffic_density: 0.50, + traffic_cars: EURO_CITY_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.025, + effects: &[ObstacleEffect::SpeedChange { affect: -0.20 }], + }], + ..COBBLE_LANE +}; + +// ─── Shoulder strips ───────────────────────────────────────────────────────── + +const ALPS_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_GUARDRAIL, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_PINE, + repeat: 6, + }, +]; +const TUNNEL_SHOULDERS_BOTH: &[Shoulder] = &[Shoulder { + style: theme::SHOULDER_CRASH_BARRIER, + repeat: 0, +}]; +const MOTORWAY_POLES_R: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_POLES, + repeat: 8, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; +const MONACO_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_PARKED_CAR, + repeat: 3, + }, +]; + +// ─── Stages ────────────────────────────────────────────────────────────────── + +const S01_LONDON: Stage = Stage { + name: "London", + description: "Start in the heart of London. Congestion charge zone. Dense traffic, narrow streets, everyone in a hurry.", + icon: theme::STAGE_METROPOLIS, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[LONDON_OUT, LONDON_OUT], + incoming: &[LONDON_IN, LONDON_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S02_M20: Stage = Stage { + name: "M20 Motorway", + description: "British motorway south toward Dover. Three lanes, moderate speed, clouds overhead as always.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 120.0, + road: Road { + lanes: Lanes { + outgoing: &[MOTORWAY_OUT, MOTORWAY_OUT], + incoming: &[MOTORWAY_IN, MOTORWAY_IN], + }, + sceneries: Sceneries { + left: HIGHWAY_SCENERY, + right: RURAL_SCENERY, + }, + ..ROAD_MOTORWAY_2X2 + }, +}; + +const S03_CHANNEL_TUNNEL: Stage = Stage { + name: "Channel Tunnel", + description: "Undersea tunnel beneath the English Channel. 50 km of darkness, 140 km/h max, controlled entry queues.", + icon: theme::STAGE_TUNNEL, + theme: Theme::Standard, + distance_km: 50.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[TUNNEL_LANE], + incoming: &[TUNNEL_LANE], + }, + sceneries: Sceneries { + left: TUNNEL_SCENERY, + right: TUNNEL_SCENERY, + }, + shoulders: Shoulders { + left: TUNNEL_SHOULDERS_BOTH, + right: TUNNEL_SHOULDERS_BOTH, + }, + }, +}; + +const S04_FRENCH_MOTORWAY: Stage = Stage { + name: "A16 / Calais", + description: "Bienvenue en France. French autoroute — toll plazas ahead, but traffic opens up fast.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 100.0, + road: Road { + lanes: Lanes { + outgoing: &[MOTORWAY_OUT, MOTORWAY_OUT], + incoming: &[MOTORWAY_IN, MOTORWAY_IN], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: MOTORWAY_POLES_R, + }, + ..ROAD_MOTORWAY_2X2 + }, +}; + +const S05_PARIS: Stage = Stage { + name: "Paris", + description: "Arc de Triomphe roundabout. Twelve lanes merge into chaos. Parisian drivers use horns liberally.", + icon: theme::STAGE_CITY, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[EURO_CITY_OUT, EURO_CITY_OUT], + incoming: &[EURO_CITY_IN, EURO_CITY_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S06_BELGIUM: Stage = Stage { + name: "Belgium / E40", + description: "Flat Belgian motorway. Some of the most densely trafficked roads in Europe. Watch the trucks.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 350.0, + road: Road { + lanes: Lanes { + outgoing: &[MOTORWAY_OUT, MOTORWAY_OUT, MOTORWAY_OUT], + incoming: &[MOTORWAY_IN, MOTORWAY_IN], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: TOWN_SCENERY, + }, + ..ROAD_MOTORWAY_3X2 + }, +}; + +const S07_AUTOBAHN: Stage = Stage { + name: "German Autobahn", + description: "No speed limit on this stretch. 340 km/h is legal. Traffic still overtakes you. Floor it.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 450.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[AUTOBAHN_FAST_OUT, AUTOBAHN_FAST_OUT, AUTOBAHN_FAST_OUT], + incoming: &[AUTOBAHN_SLOW_IN, AUTOBAHN_SLOW_IN], + }, + sceneries: Sceneries { + left: HIGHWAY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: MOTORWAY_POLES_R, + }, + }, +}; + +const S08_FRANKFURT: Stage = Stage { + name: "Frankfurt", + description: "Germany's financial hub. Skyline of glass towers ahead. Speed drops back to city limits.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + lanes: Lanes { + outgoing: &[EURO_CITY_OUT], + incoming: &[EURO_CITY_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: TOWN_SCENERY, + }, + ..ROAD_CITY_2X2 + }, +}; + +const S09_AUSTRIA: Stage = Stage { + name: "Austrian Motorway", + description: "Into Austria. Mountains appear on the horizon. Alpine air through the window.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 250.0, + road: Road { + lanes: Lanes { + outgoing: &[ALPINE_MOTORWAY_OUT, ALPINE_MOTORWAY_OUT], + incoming: &[ALPINE_MOTORWAY_IN, ALPINE_MOTORWAY_IN], + }, + sceneries: Sceneries { + left: MOUNTAIN_SCENERY, + right: HIGHWAY_SCENERY, + }, + ..ROAD_MOTORWAY_2X2 + }, +}; + +const S10_SWISS_ALPS: Stage = Stage { + name: "Swiss/Austrian Alps", + description: "Switchbacks, guardrails, and ice. One lane each way. 3,000m peaks all around. Take it slow.", + icon: theme::STAGE_MOUNTAIN, + theme: Theme::Winter, + distance_km: 150.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ALPS_LANE], + incoming: &[ALPS_LANE], + }, + sceneries: Sceneries { + left: ALPINE_SCENERY, + right: ALPINE_SCENERY, + }, + shoulders: Shoulders { + left: ALPS_SHOULDERS, + right: ALPS_SHOULDERS, + }, + }, +}; + +const S11_BRENNER: Stage = Stage { + name: "Brenner Pass", + description: "Historic Alpine crossing at 1,370m. Road works every season. Icy patches linger well into spring.", + icon: theme::STAGE_MOUNTAIN, + theme: Theme::Winter, + distance_km: 100.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[BRENNER_LANE], + incoming: &[BRENNER_LANE], + }, + sceneries: Sceneries { + left: ALPINE_SCENERY, + right: MOUNTAIN_SCENERY, + }, + shoulders: Shoulders { + left: ALPS_SHOULDERS, + right: ALPS_SHOULDERS, + }, + }, +}; + +const S12_NORTH_ITALY: Stage = Stage { + name: "Northern Italy / A22", + description: "Coming down from the Alps into the Po Valley. Speed climbs. Warm Italian air ahead.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 180.0, + road: Road { + lanes: Lanes { + outgoing: &[ALPINE_MOTORWAY_OUT, ALPINE_MOTORWAY_OUT], + incoming: &[ALPINE_MOTORWAY_IN, ALPINE_MOTORWAY_IN], + }, + sceneries: Sceneries { + left: EURO_VILLAGE_SCENERY, + right: MOUNTAIN_SCENERY, + }, + ..ROAD_MOTORWAY_2X2 + }, +}; + +const S13_MILAN: Stage = Stage { + name: "Milan", + description: "Italian fashion capital. Historic centro storico on each side. Traffic is creative, not orderly.", + icon: theme::STAGE_CITY, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[EURO_CITY_OUT, EURO_CITY_OUT], + incoming: &[EURO_CITY_IN, EURO_CITY_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: EURO_VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S14_FRENCH_RIVIERA: Stage = Stage { + name: "French Riviera / A8", + description: "Azure coast. Palm trees, azure sea on your right, limestone cliffs on your left. Worth every kilometre.", + icon: theme::STAGE_COASTAL, + theme: Theme::Standard, + distance_km: 200.0, + road: Road { + lanes: Lanes { + outgoing: &[MOTORWAY_OUT, MOTORWAY_OUT], + incoming: &[MOTORWAY_IN], + }, + sceneries: Sceneries { + left: MOUNTAIN_SCENERY, + right: COASTAL_SCENERY, + }, + ..ROAD_MOTORWAY_2X2 + }, +}; + +const S15_MONACO: Stage = Stage { + name: "Monaco", + description: "Principality of Monaco. Cobblestones, hairpin bends, parked Ferraris on the shoulder. Slowest stage, best scenery.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MONACO_LANE], + incoming: &[MONACO_LANE], + }, + sceneries: Sceneries { + left: COASTAL_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: MONACO_SHOULDERS, + right: MONACO_SHOULDERS, + }, + }, +}; + +const S16_BARCELONA: Stage = Stage { + name: "Barcelona", + description: "Capital of Catalonia. Gaudí's towers on the horizon. La Rambla a block away. You made it.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + lanes: Lanes { + outgoing: &[EURO_CITY_OUT, EURO_CITY_OUT], + incoming: &[EURO_CITY_IN, EURO_CITY_IN], + }, + sceneries: Sceneries { + left: COASTAL_SCENERY, + right: CITY_SCENERY, + }, + ..ROAD_CITY_2X2 + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "Eurotrip", + author: "claude", + description: "London to Barcelona. English Channel tunnel, Parisian chaos, limitless German Autobahn, Alpine ice, Italian sunshine, Monaco cobblestones. Every stage feels different.", + stages: &[ + S01_LONDON, + S02_M20, + S03_CHANNEL_TUNNEL, + S04_FRENCH_MOTORWAY, + S05_PARIS, + S06_BELGIUM, + S07_AUTOBAHN, + S08_FRANKFURT, + S09_AUSTRIA, + S10_SWISS_ALPS, + S11_BRENNER, + S12_NORTH_ITALY, + S13_MILAN, + S14_FRENCH_RIVIERA, + S15_MONACO, + S16_BARCELONA, + ], + distance_scale: 0.019, + speed_scale: 2.0, + lives: 3, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/fantasy.rs b/late-ssh/src/app/arcade/traffic/tracks/fantasy.rs new file mode 100644 index 00000000..923767c6 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/fantasy.rs @@ -0,0 +1,423 @@ +//! The Realm — a fantasy journey through magical lands. +//! +//! Cobblestone villages → enchanted forests → crystal caves → dragon's lava pass +//! → a bridge over pure void → ancient ruins → the Dark Realm → elven highway +//! → the obsidian Citadel → Realm's End. ~78 km, ~14 min. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Scenery, Shoulders, Stage, + Theme, Track, +}; + +// ─── Lane variants ──────────────────────────────────────────────────────────── + +const VILLAGE_LANE: Lane = Lane { + own_max_speed: 100.0, + traffic_density: 0.35, + traffic_cars: FANTASY_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.022, + effects: &[ObstacleEffect::SpeedChange { affect: -0.65 }], + }], + ..COBBLE_LANE +}; + +const ENCHANTED_LANE: Lane = Lane { + own_max_speed: 140.0, + passive_decel: 2.0, + traffic_density: 0.1, + traffic_cars: FANTASY_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.022, + effects: &[ObstacleEffect::SpeedChange { affect: -0.25 }], + }], + ..FOREST_LANE +}; + +const CRYSTAL_CAVE_LANE: Lane = Lane { + own_max_speed: 120.0, + traffic_density: 0.1, + traffic_cars: FANTASY_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_MAGIC_TRAP, + frequency: 0.024, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 450 }, + ObstacleEffect::SpeedChange { affect: -0.45 }, + ], + }], + ..CRYSTAL_LANE +}; + +const DRAGONS_PASS_LANE: Lane = Lane { + own_max_speed: 100.0, + traffic_density: 0.1, + traffic_cars: FANTASY_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_DRAGON_FIRE, + frequency: 0.035, + effects: &[ObstacleEffect::SpeedChange { affect: -0.72 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.012, + effects: &[ObstacleEffect::SpeedChange { affect: -0.85 }], + }, + ], + ..MOUNTAIN_LANE +}; + +const RUINS_LANE: Lane = Lane { + own_max_speed: 90.0, + traffic_density: 0.1, + traffic_cars: FANTASY_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_FALLEN_TREE, + frequency: 0.032, + effects: &[ObstacleEffect::SpeedChange { affect: -0.80 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.014, + effects: &[ObstacleEffect::SpeedChange { affect: -0.70 }], + }, + ], + ..MOUNTAIN_LANE +}; + +const DARK_REALM_LANE: Lane = Lane { + own_max_speed: 110.0, + traffic_density: 0.12, + traffic_cars: FANTASY_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_MAGIC_TRAP, + frequency: 0.042, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 600 }, + ObstacleEffect::SpeedChange { affect: -0.55 }, + ], + }, + Obstacle { + style: theme::OBSTACLE_SPIKES, + frequency: 0.014, + effects: &[ObstacleEffect::Crash], + }, + ], + ..CRYSTAL_LANE +}; + +const ELVEN_LANE: Lane = Lane { + own_max_speed: 200.0, + traffic_density: 0.10, + traffic_cars: FANTASY_MIX, + ..HIGHWAY_LANE +}; + +const CITADEL_LANE: Lane = Lane { + own_max_speed: 80.0, + traffic_density: 0.4, + traffic_cars: FANTASY_MIX, + ..COBBLE_LANE +}; + +const REALM_END_LANE: Lane = Lane { + own_max_speed: 180.0, + traffic_density: 0.1, + traffic_cars: FANTASY_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_MAGIC_TRAP, + frequency: 0.018, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 300 }, + ObstacleEffect::SpeedChange { affect: -0.30 }, + ], + }], + ..CRYSTAL_LANE +}; + +// ─── Local sceneries ───────────────────────────────────────────────────────── + +const VOID_SIDES: Scenery = Scenery { + width: 6, + background: theme::SCENERY_VOID, + objects: &[], +}; + +// ─── Stages ────────────────────────────────────────────────────────────────── + +const S01_BRAMBLEWOOD: Stage = Stage { + name: "Village of Bramblewood", + description: "Cobblestone streets, lantern light, smoke from chimney-tops. A cheerful start to an extraordinary journey.", + icon: theme::STAGE_VILLAGE, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[VILLAGE_LANE, VILLAGE_LANE], + incoming: &[VILLAGE_LANE], + }, + sceneries: Sceneries { + left: EURO_VILLAGE_SCENERY, + right: EURO_VILLAGE_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S02_ENCHANTED_FOREST: Stage = Stage { + name: "Enchanted Forest", + description: "Bioluminescent mushrooms line the path. Giant oaks whisper ancient secrets. Something watches from the undergrowth.", + icon: theme::STAGE_WILD_FOREST, + theme: Theme::Standard, + distance_km: 12.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ENCHANTED_LANE], + incoming: &[ENCHANTED_LANE], + }, + sceneries: Sceneries { + left: MAGIC_FOREST_SCENERY, + right: MAGIC_FOREST_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S03_CRYSTAL_CAVES: Stage = Stage { + name: "Glimmering Caves", + description: "Veins of pure crystal pulse with inner light. The road cuts through the mountain's heart. Magic traps shimmer ahead — don't be lured.", + icon: theme::STAGE_TUNNEL, + theme: Theme::Winter, + distance_km: 6.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[CRYSTAL_CAVE_LANE], + incoming: &[CRYSTAL_CAVE_LANE], + }, + sceneries: Sceneries { + left: CRYSTAL_CAVE_SCENERY, + right: CRYSTAL_CAVE_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S04_DRAGONS_PASS: Stage = Stage { + name: "Dragon's Pass", + description: "Lava rivers glow on both sides. A great dragon banks overhead and breathes fire across the road. Keep moving.", + icon: theme::STAGE_DRAGON, + theme: Theme::Desert, + distance_km: 10.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[DRAGONS_PASS_LANE], + incoming: &[DRAGONS_PASS_LANE], + }, + sceneries: Sceneries { + left: LAVA_SCENERY, + right: LAVA_SCENERY, + }, + shoulders: Shoulders { + left: GUARDRAIL_SHOULDERS, + right: GUARDRAIL_SHOULDERS, + }, + }, +}; + +const S05_FLOATING_BRIDGE: Stage = Stage { + name: "The Floating Bridge", + description: "Crystal causeway suspended above pure void. No railings. No ground below. Just the road and the abyss.", + icon: theme::STAGE_BRIDGE, + theme: Theme::Standard, + distance_km: 5.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[CRYSTAL_CAVE_LANE], + incoming: &[CRYSTAL_CAVE_LANE], + }, + sceneries: Sceneries { + left: VOID_SIDES, + right: VOID_SIDES, + }, + shoulders: NO_SHOULDERS, + }, +}; + +const S06_ANCIENT_RUINS: Stage = Stage { + name: "Ancient Ruins", + description: "Collapsed pillars block the road. Roots burst through stone. Something old and forgotten stirs beneath the rubble.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[RUINS_LANE], + incoming: &[RUINS_LANE], + }, + sceneries: Sceneries { + left: DARK_REALM_SCENERY, + right: MAGIC_FOREST_SCENERY, + }, + shoulders: Shoulders { + left: GUARDRAIL_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S07_DARK_REALM: Stage = Stage { + name: "The Dark Realm", + description: "Crystal paths warp through a space where light bends wrong. Magic traps pulse like heartbeats. Spiked barriers wait for the unwary.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[DARK_REALM_LANE, DARK_REALM_LANE], + incoming: &[DARK_REALM_LANE], + }, + sceneries: Sceneries { + left: DARK_REALM_SCENERY, + right: DARK_REALM_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S08_ELVEN_CAUSEWAY: Stage = Stage { + name: "Elven Causeway", + description: "Impossibly smooth magic-woven road through an ancient enchanted forest. Elven runes carved every hundred metres accelerate you onward.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 10.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ELVEN_LANE, ELVEN_LANE], + incoming: &[ELVEN_LANE, ELVEN_LANE], + }, + sceneries: Sceneries { + left: MAGIC_FOREST_SCENERY, + right: MAGIC_FOREST_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +const S09_THE_CITADEL: Stage = Stage { + name: "The Citadel", + description: "Towers of obsidian rise above cobblestone avenues. Dense crowds, dark spires, and the smell of forgotten wars.", + icon: theme::STAGE_CASTLE, + theme: Theme::Standard, + distance_km: 6.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[CITADEL_LANE, CITADEL_LANE], + incoming: &[CITADEL_LANE], + }, + sceneries: Sceneries { + left: DARK_REALM_SCENERY, + right: DARK_REALM_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S10_REALMS_END: Stage = Stage { + name: "Realm's End", + description: "The road dissolves into crystal light. Magic traps — or blessings? — flicker across the final stretch. Beyond this point lies only legend.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Winter, + distance_km: 5.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[REALM_END_LANE, REALM_END_LANE], + incoming: &[REALM_END_LANE], + }, + sceneries: Sceneries { + left: CRYSTAL_CAVE_SCENERY, + right: MAGIC_FOREST_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "The Realm", + author: "claude", + description: "A fantasy journey through enchanted lands: cobblestone villages, luminous crystal caves, a dragon-guarded lava pass, a bridge over pure void, ancient ruins, the terrifying Dark Realm, the legendary Elven Causeway, and the obsidian Citadel at the edge of all things.", + stages: &[ + S01_BRAMBLEWOOD, + S02_ENCHANTED_FOREST, + S03_CRYSTAL_CAVES, + S04_DRAGONS_PASS, + S05_FLOATING_BRIDGE, + S06_ANCIENT_RUINS, + S07_DARK_REALM, + S08_ELVEN_CAUSEWAY, + S09_THE_CITADEL, + S10_REALMS_END, + ], + distance_scale: 0.45, + speed_scale: 2.0, + lives: 3, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/mod.rs b/late-ssh/src/app/arcade/traffic/tracks/mod.rs new file mode 100644 index 00000000..7400a3c1 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/mod.rs @@ -0,0 +1,48 @@ +//! Hard-coded track catalogue. +//! +//! Tracks live in their own files in this directory and are registered here. + +pub mod batin; +pub mod crazy; +pub mod eurotrip; +pub mod fantasy; +pub mod presets; +pub mod route66; +pub mod solar_system; + +#[cfg(debug_assertions)] +pub mod sample; +#[cfg(debug_assertions)] +pub mod test; + +use super::track::Track; + +/// Every track available in the picker, in display order. +#[cfg(debug_assertions)] +pub const ALL_TRACKS: &[&Track] = &[ + &test::TRACK, + &sample::TRACK, + &batin::TRACK, + &route66::TRACK, + &eurotrip::TRACK, + &fantasy::TRACK, + &solar_system::TRACK, + &crazy::TRACK, +]; + +#[cfg(not(debug_assertions))] +pub const ALL_TRACKS: &[&Track] = &[ + &batin::TRACK, + &route66::TRACK, + &eurotrip::TRACK, + &fantasy::TRACK, + &solar_system::TRACK, + &crazy::TRACK, +]; + +/// Default track loaded when none has been selected yet. +#[cfg(debug_assertions)] +pub const DEFAULT_TRACK: &Track = &test::TRACK; + +#[cfg(not(debug_assertions))] +pub const DEFAULT_TRACK: &Track = &batin::TRACK; diff --git a/late-ssh/src/app/arcade/traffic/tracks/presets.rs b/late-ssh/src/app/arcade/traffic/tracks/presets.rs new file mode 100644 index 00000000..e4268d3c --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/presets.rs @@ -0,0 +1,1004 @@ +//! Reusable building blocks for track authoring. +//! +//! Tracks should compose these via struct-update syntax instead of duplicating +//! field-by-field configurations. Example: +//! +//! ```ignore +//! use crate::app::arcade::traffic::tracks::presets; +//! +//! const FAST_LANE: Lane = Lane { +//! style: theme::LANE_ASPHALT_PREMIUM, +//! own_max_speed: 220.0, +//! ..presets::HIGHWAY_LANE +//! }; +//! ``` + +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Car, Divider, Lane, Lanes, Object, Road, RoadAspect, Sceneries, Scenery, Shoulder, Shoulders, +}; + +// ─── Car shapes ────────────────────────────────────────────────────────────── + +pub const CAR_SEDAN: Car = Car { + height: 3, + incidence: 0.45, +}; +pub const CAR_HATCHBACK: Car = Car { + height: 3, + incidence: 0.25, +}; +pub const CAR_VAN: Car = Car { + height: 5, + incidence: 0.20, +}; +pub const CAR_TRUCK: Car = Car { + height: 7, + incidence: 0.08, +}; +pub const CAR_SEMI: Car = Car { + height: 11, + incidence: 0.02, +}; +pub const CAR_PICKUP: Car = Car { + height: 3, + incidence: 0.35, +}; +pub const CAR_RV: Car = Car { + height: 7, + incidence: 0.08, +}; +pub const CAR_MICRO: Car = Car { + height: 2, + incidence: 0.40, +}; +pub const CAR_CART: Car = Car { + height: 4, + incidence: 0.30, +}; +pub const CAR_MONSTER: Car = Car { + height: 14, + incidence: 0.05, +}; +pub const CAR_FIGHTER: Car = Car { + height: 2, + incidence: 0.45, +}; +pub const CAR_FREIGHTER: Car = Car { + height: 9, + incidence: 0.20, +}; + +pub const CITY_CAR_MIX: &[Car] = &[CAR_SEDAN, CAR_HATCHBACK, CAR_VAN]; +pub const HIGHWAY_CAR_MIX: &[Car] = &[CAR_SEDAN, CAR_HATCHBACK, CAR_VAN, CAR_TRUCK, CAR_SEMI]; +pub const RURAL_CAR_MIX: &[Car] = &[CAR_HATCHBACK, CAR_VAN, CAR_TRUCK]; +pub const AMERICAN_CAR_MIX: &[Car] = &[CAR_SEDAN, CAR_PICKUP, CAR_VAN, CAR_RV, CAR_TRUCK]; +pub const INTERSTATE_CAR_MIX: &[Car] = + &[CAR_SEDAN, CAR_PICKUP, CAR_VAN, CAR_TRUCK, CAR_SEMI, CAR_RV]; +pub const EURO_CITY_MIX: &[Car] = &[CAR_MICRO, CAR_HATCHBACK, CAR_SEDAN, CAR_VAN]; +pub const EURO_HIGHWAY_MIX: &[Car] = &[CAR_SEDAN, CAR_HATCHBACK, CAR_VAN, CAR_TRUCK, CAR_SEMI]; +pub const FANTASY_MIX: &[Car] = &[CAR_HATCHBACK, CAR_SEDAN, CAR_CART, CAR_VAN]; +pub const SPACE_SHIP_MIX: &[Car] = &[CAR_FIGHTER, CAR_SEDAN, CAR_VAN, CAR_FREIGHTER]; +pub const CRAZY_MIX: &[Car] = &[ + CAR_MICRO, + CAR_HATCHBACK, + CAR_VAN, + CAR_TRUCK, + CAR_SEMI, + CAR_MONSTER, +]; + +// ─── Lane templates ────────────────────────────────────────────────────────── + +pub const CITY_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_STANDARD, + own_min_speed: 0.0, + own_max_speed: 150.0, + passive_decel: 0.0, + traffic_min_speed: 40.0, + traffic_max_speed: 90.0, + traffic_density: 0.4, + traffic_cars: CITY_CAR_MIX, + obstacles: &[], +}; + +pub const HIGHWAY_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PREMIUM, + own_min_speed: 50.0, + own_max_speed: 260.0, + passive_decel: 0.0, + traffic_min_speed: 60.0, + traffic_max_speed: 150.0, + traffic_density: 0.25, + traffic_cars: HIGHWAY_CAR_MIX, + obstacles: &[], +}; + +pub const RURAL_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PATCHY, + own_min_speed: 0.0, + own_max_speed: 100.0, + passive_decel: 2.0, + traffic_min_speed: 30.0, + traffic_max_speed: 80.0, + traffic_density: 0.2, + traffic_cars: RURAL_CAR_MIX, + obstacles: &[], +}; + +pub const FOREST_LANE: Lane = Lane { + style: theme::LANE_DIRT, + own_min_speed: 0.0, + own_max_speed: 70.0, + passive_decel: 4.0, + traffic_min_speed: 20.0, + traffic_max_speed: 50.0, + traffic_density: 0.1, + traffic_cars: RURAL_CAR_MIX, + obstacles: &[], +}; + +pub const INTERSTATE_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PREMIUM, + own_min_speed: 40.0, + own_max_speed: 200.0, + passive_decel: 0.0, + traffic_min_speed: 65.0, + traffic_max_speed: 130.0, + traffic_density: 0.18, + traffic_cars: INTERSTATE_CAR_MIX, + obstacles: &[], +}; + +pub const MOTORWAY_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PREMIUM, + own_min_speed: 60.0, + own_max_speed: 200.0, + passive_decel: 0.0, + traffic_min_speed: 80.0, + traffic_max_speed: 140.0, + traffic_density: 0.22, + traffic_cars: EURO_HIGHWAY_MIX, + obstacles: &[], +}; + +pub const AUTOBAHN_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PREMIUM, + own_min_speed: 0.0, + own_max_speed: 340.0, + passive_decel: 0.0, + traffic_min_speed: 100.0, + traffic_max_speed: 220.0, + traffic_density: 0.12, + traffic_cars: EURO_HIGHWAY_MIX, + obstacles: &[], +}; + +pub const COBBLE_LANE: Lane = Lane { + style: theme::LANE_COBBLESTONE, + own_min_speed: 0.0, + own_max_speed: 60.0, + passive_decel: 2.0, + traffic_min_speed: 18.0, + traffic_max_speed: 50.0, + traffic_density: 0.4, + traffic_cars: EURO_CITY_MIX, + obstacles: &[], +}; + +pub const MOUNTAIN_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_PATCHY, + own_min_speed: 0.0, + own_max_speed: 80.0, + passive_decel: 3.0, + traffic_min_speed: 20.0, + traffic_max_speed: 60.0, + traffic_density: 0.10, + traffic_cars: RURAL_CAR_MIX, + obstacles: &[], +}; + +pub const DESERT_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_CRACKED, + own_min_speed: 0.0, + own_max_speed: 140.0, + passive_decel: 1.0, + traffic_min_speed: 40.0, + traffic_max_speed: 95.0, + traffic_density: 0.1, + traffic_cars: AMERICAN_CAR_MIX, + obstacles: &[], +}; + +pub const PLAINS_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_STANDARD, + own_min_speed: 0.0, + own_max_speed: 160.0, + passive_decel: 0.0, + traffic_min_speed: 60.0, + traffic_max_speed: 110.0, + traffic_density: 0.10, + traffic_cars: AMERICAN_CAR_MIX, + obstacles: &[], +}; + +pub const CRYSTAL_LANE: Lane = Lane { + style: theme::LANE_CRYSTAL, + own_min_speed: 0.0, + own_max_speed: 120.0, + passive_decel: 0.0, + traffic_min_speed: 25.0, + traffic_max_speed: 80.0, + traffic_density: 0.20, + traffic_cars: FANTASY_MIX, + obstacles: &[], +}; + +pub const SPACE_LANE: Lane = Lane { + style: theme::LANE_SPACE, + own_min_speed: 0.0, + own_max_speed: 320.0, + passive_decel: 0.0, + traffic_min_speed: 70.0, + traffic_max_speed: 210.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[], +}; + +pub const TURBO_LANE: Lane = Lane { + style: theme::LANE_NEON, + own_min_speed: 0.0, + own_max_speed: 600.0, + passive_decel: 0.0, + traffic_min_speed: 120.0, + traffic_max_speed: 420.0, + traffic_density: 0.1, + traffic_cars: CRAZY_MIX, + obstacles: &[], +}; + +pub const GRIDLOCK_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_STANDARD, + own_min_speed: 0.0, + own_max_speed: 28.0, + passive_decel: 4.0, + traffic_min_speed: 4.0, + traffic_max_speed: 22.0, + traffic_density: 0.8, + traffic_cars: CRAZY_MIX, + obstacles: &[], +}; + +// ─── Dividers ──────────────────────────────────────────────────────────────── + +pub const URBAN_DIVIDERS: Divider = Divider { + primary: theme::DIV_YELLOW_SINGLE, + lane: theme::DIV_WHITE_DASH, +}; +pub const HIGHWAY_DIVIDERS: Divider = Divider { + primary: theme::DIV_YELLOW_DOUBLE, + lane: theme::DIV_WHITE_DASH, +}; +pub const RURAL_DIVIDERS: Divider = Divider { + primary: theme::DIV_YELLOW_DASH, + lane: theme::DIV_FAINT, +}; +pub const FOREST_DIVIDERS: Divider = Divider { + primary: theme::DIV_FAINT, + lane: theme::DIV_NONE, +}; + +// ─── Scenery objects ───────────────────────────────────────────────────────── + +pub const CITY_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 0.35, + }, + Object { + style: theme::OBJ_BUILDING_APARTMENTS, + incidence: 0.25, + }, + Object { + style: theme::OBJ_SKYSCRAPER, + incidence: 0.10, + }, + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.20, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.10, + }, +]; + +pub const TOWN_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 0.35, + }, + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.20, + }, + Object { + style: theme::OBJ_GRASS, + incidence: 0.20, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.10, + }, +]; + +pub const HIGHWAY_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_TREE_PINE, + incidence: 0.50, + }, + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.25, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.20, + }, + Object { + style: theme::OBJ_FLOWER, + incidence: 0.05, + }, +]; + +pub const RURAL_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.45, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.25, + }, + Object { + style: theme::OBJ_GRASS, + incidence: 0.20, + }, + Object { + style: theme::OBJ_FLOWER, + incidence: 0.10, + }, +]; + +pub const VILLAGE_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_BUSH, + incidence: 0.25, + }, + Object { + style: theme::OBJ_GRASS, + incidence: 0.20, + }, + Object { + style: theme::OBJ_FLOWER, + incidence: 0.10, + }, + Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 0.20, + }, +]; + +pub const FOREST_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_TREE_PINE, + incidence: 0.80, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.20, + }, +]; + +pub const DESERT_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_GRASS, + incidence: 0.40, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.40, + }, + Object { + style: theme::OBJ_TREE_PALM, + incidence: 0.20, + }, +]; + +pub const PLAINS_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_GRASS, + incidence: 0.45, + }, + Object { + style: theme::OBJ_WINDMILL, + incidence: 0.20, + }, + Object { + style: theme::OBJ_BARN, + incidence: 0.15, + }, + Object { + style: theme::OBJ_BILLBOARD, + incidence: 0.10, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.10, + }, +]; + +pub const SOUTHWESTERN_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_CACTUS, + incidence: 0.45, + }, + Object { + style: theme::OBJ_ROCK, + incidence: 0.30, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.20, + }, + Object { + style: theme::OBJ_GRASS, + incidence: 0.05, + }, +]; + +pub const MOUNTAIN_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_ROCK, + incidence: 0.55, + }, + Object { + style: theme::OBJ_TREE_PINE, + incidence: 0.30, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.15, + }, +]; + +pub const ALPINE_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_ROCK, + incidence: 0.65, + }, + Object { + style: theme::OBJ_TREE_PINE, + incidence: 0.25, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.10, + }, +]; + +pub const COASTAL_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_TREE_PALM, + incidence: 0.40, + }, + Object { + style: theme::OBJ_FLOWER, + incidence: 0.35, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.25, + }, +]; + +pub const EURO_VILLAGE_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 0.30, + }, + Object { + style: theme::OBJ_CHURCH, + incidence: 0.15, + }, + Object { + style: theme::OBJ_VINEYARD, + incidence: 0.25, + }, + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.20, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.10, + }, +]; + +pub const MAGIC_FOREST_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_MUSHROOM, + incidence: 0.28, + }, + Object { + style: theme::OBJ_TREE_OAK, + incidence: 0.30, + }, + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.22, + }, + Object { + style: theme::OBJ_BUSH, + incidence: 0.20, + }, +]; + +pub const DARK_REALM_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_DARK_TOWER, + incidence: 0.22, + }, + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.35, + }, + Object { + style: theme::OBJ_ROCK, + incidence: 0.25, + }, + Object { + style: theme::OBJ_STAR, + incidence: 0.18, + }, +]; + +pub const CRYSTAL_CAVE_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.65, + }, + Object { + style: theme::OBJ_STAR, + incidence: 0.25, + }, + Object { + style: theme::OBJ_ROCK, + incidence: 0.10, + }, +]; + +pub const LAVA_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_ROCK, + incidence: 0.70, + }, + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.30, + }, +]; + +pub const SPACE_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_STAR, + incidence: 0.55, + }, + Object { + style: theme::OBJ_NEBULA, + incidence: 0.20, + }, + Object { + style: theme::OBJ_PLANET_SMALL, + incidence: 0.12, + }, + Object { + style: theme::OBJ_COMET, + incidence: 0.13, + }, +]; + +// ─── Sceneries ─────────────────────────────────────────────────────────────── + +pub const CITY_SCENERY: Scenery = Scenery { + width: 14, + background: theme::SCENERY_CONCRETE, + objects: CITY_OBJECTS, +}; +pub const TOWN_SCENERY: Scenery = Scenery { + width: 14, + background: theme::SCENERY_CONCRETE, + objects: TOWN_OBJECTS, +}; +pub const HIGHWAY_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_GRASS, + objects: HIGHWAY_OBJECTS, +}; +pub const RURAL_SCENERY: Scenery = Scenery { + width: 10, + background: theme::SCENERY_GRASS, + objects: RURAL_OBJECTS, +}; +pub const VILLAGE_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_GRASS, + objects: VILLAGE_OBJECTS, +}; +pub const FOREST_SCENERY: Scenery = Scenery { + width: 14, + background: theme::SCENERY_DIRT, + objects: FOREST_OBJECTS, +}; +pub const DESERT_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_DIRT, + objects: DESERT_OBJECTS, +}; +pub const PLAINS_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_GRASS, + objects: PLAINS_OBJECTS, +}; +pub const SOUTHWESTERN_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_SAND, + objects: SOUTHWESTERN_OBJECTS, +}; +pub const MOUNTAIN_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_DIRT, + objects: MOUNTAIN_OBJECTS, +}; +pub const ALPINE_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_SNOW, + objects: ALPINE_OBJECTS, +}; +pub const COASTAL_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_GRASS, + objects: COASTAL_OBJECTS, +}; +pub const TUNNEL_SCENERY: Scenery = Scenery { + width: 4, + background: theme::SCENERY_VOID, + objects: &[], +}; +pub const EURO_VILLAGE_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_GRASS, + objects: EURO_VILLAGE_OBJECTS, +}; +pub const MAGIC_FOREST_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_MAGIC, + objects: MAGIC_FOREST_OBJECTS, +}; +pub const DARK_REALM_SCENERY: Scenery = Scenery { + width: 10, + background: theme::SCENERY_MAGIC, + objects: DARK_REALM_OBJECTS, +}; +pub const CRYSTAL_CAVE_SCENERY: Scenery = Scenery { + width: 8, + background: theme::SCENERY_VOID, + objects: CRYSTAL_CAVE_OBJECTS, +}; +pub const LAVA_SCENERY: Scenery = Scenery { + width: 10, + background: theme::SCENERY_LAVA, + objects: LAVA_OBJECTS, +}; +pub const STARFIELD_SCENERY: Scenery = Scenery { + width: 14, + background: theme::SCENERY_STARFIELD, + objects: SPACE_OBJECTS, +}; + +// ─── Shoulders ─────────────────────────────────────────────────────────────── + +pub const SIDEWALK_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_SIDEWALK, + repeat: 0, + }, +]; + +pub const HIGHWAY_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_HARD_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_POLES, + repeat: 6, + }, +]; + +pub const RURAL_SHOULDERS: &[Shoulder] = &[Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, +}]; + +pub const FOREST_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_PINE, + repeat: 4, + }, +]; + +pub const NO_SHOULDERS: Shoulders = Shoulders { + left: &[], + right: &[], +}; + +pub const GUARDRAIL_SHOULDERS: &[Shoulder] = &[Shoulder { + style: theme::SHOULDER_GUARDRAIL, + repeat: 0, +}]; + +pub const CRASH_BARRIER_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_CRASH_BARRIER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; + +pub const MAGIC_RUNE_SHOULDERS: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_MAGIC_RUNE, + repeat: 10, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; + +pub const SPACE_BEACON_SHOULDERS: &[Shoulder] = &[Shoulder { + style: theme::SHOULDER_SPACE_BEACON, + repeat: 0, +}]; + +pub const NEON_BARRIER_SHOULDERS: &[Shoulder] = &[Shoulder { + style: theme::SHOULDER_NEON_BARRIER, + repeat: 0, +}]; + +// ─── Pre-built Road configurations (builder framework) ────────────────────── +// +// Use struct-update syntax to customise scenery or lanes while reusing the +// divider, shoulder, and base lane configuration. Example: +// +// road: Road { +// sceneries: Sceneries { left: FOREST_SCENERY, right: MOUNTAIN_SCENERY }, +// ..ROAD_HIGHWAY_2X2 +// } + +pub const ROAD_CITY_2X2: Road = Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + incoming: &[CITY_LANE, CITY_LANE], + outgoing: &[CITY_LANE, CITY_LANE], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, +}; + +pub const ROAD_TOWN_2X2: Road = Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + incoming: &[CITY_LANE, CITY_LANE], + outgoing: &[CITY_LANE, CITY_LANE], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: TOWN_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, +}; + +pub const ROAD_HIGHWAY_2X2: Road = Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + incoming: &[HIGHWAY_LANE, HIGHWAY_LANE], + outgoing: &[HIGHWAY_LANE, HIGHWAY_LANE], + }, + sceneries: Sceneries { + left: HIGHWAY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: HIGHWAY_SHOULDERS, + }, +}; + +pub const ROAD_HIGHWAY_3X2: Road = Road { + lanes: Lanes { + incoming: &[HIGHWAY_LANE, HIGHWAY_LANE], + outgoing: &[HIGHWAY_LANE, HIGHWAY_LANE, HIGHWAY_LANE], + }, + ..ROAD_HIGHWAY_2X2 +}; + +pub const ROAD_RURAL_1X1: Road = Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + incoming: &[RURAL_LANE], + outgoing: &[RURAL_LANE], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: RURAL_SHOULDERS, + right: RURAL_SHOULDERS, + }, +}; + +pub const ROAD_FOREST_1X1: Road = Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + incoming: &[FOREST_LANE], + outgoing: &[FOREST_LANE], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: Shoulders { + left: FOREST_SHOULDERS, + right: FOREST_SHOULDERS, + }, +}; + +pub const ROAD_MOTORWAY_2X2: Road = Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + incoming: &[MOTORWAY_LANE, MOTORWAY_LANE], + outgoing: &[MOTORWAY_LANE, MOTORWAY_LANE], + }, + sceneries: Sceneries { + left: HIGHWAY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: HIGHWAY_SHOULDERS, + }, +}; + +pub const ROAD_MOTORWAY_3X2: Road = Road { + lanes: Lanes { + incoming: &[MOTORWAY_LANE, MOTORWAY_LANE], + outgoing: &[MOTORWAY_LANE, MOTORWAY_LANE, MOTORWAY_LANE], + }, + ..ROAD_MOTORWAY_2X2 +}; + +pub const ROAD_INTERSTATE_2X2: Road = Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + incoming: &[INTERSTATE_LANE, INTERSTATE_LANE], + outgoing: &[INTERSTATE_LANE, INTERSTATE_LANE], + }, + sceneries: Sceneries { + left: PLAINS_SCENERY, + right: PLAINS_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: HIGHWAY_SHOULDERS, + }, +}; + +pub const ROAD_MOUNTAIN_1X1: Road = Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + incoming: &[MOUNTAIN_LANE], + outgoing: &[MOUNTAIN_LANE], + }, + sceneries: Sceneries { + left: MOUNTAIN_SCENERY, + right: MOUNTAIN_SCENERY, + }, + shoulders: Shoulders { + left: GUARDRAIL_SHOULDERS, + right: GUARDRAIL_SHOULDERS, + }, +}; + +pub const ROAD_CRYSTAL_1X1: Road = Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + incoming: &[CRYSTAL_LANE], + outgoing: &[CRYSTAL_LANE], + }, + sceneries: Sceneries { + left: CRYSTAL_CAVE_SCENERY, + right: CRYSTAL_CAVE_SCENERY, + }, + shoulders: Shoulders { + left: MAGIC_RUNE_SHOULDERS, + right: MAGIC_RUNE_SHOULDERS, + }, +}; + +pub const ROAD_SPACE_2X2: Road = Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + incoming: &[SPACE_LANE, SPACE_LANE], + outgoing: &[SPACE_LANE, SPACE_LANE], + }, + sceneries: Sceneries { + left: STARFIELD_SCENERY, + right: STARFIELD_SCENERY, + }, + shoulders: Shoulders { + left: SPACE_BEACON_SHOULDERS, + right: SPACE_BEACON_SHOULDERS, + }, +}; + +pub const ROAD_SPACE_3X2: Road = Road { + lanes: Lanes { + incoming: &[SPACE_LANE, SPACE_LANE], + outgoing: &[SPACE_LANE, SPACE_LANE, SPACE_LANE], + }, + ..ROAD_SPACE_2X2 +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/route66.rs b/late-ssh/src/app/arcade/traffic/tracks/route66.rs new file mode 100644 index 00000000..6e3cbc07 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/route66.rs @@ -0,0 +1,540 @@ +//! Route 66 — Chicago, IL to Santa Monica, CA. +//! +//! The Mother Road. ~3,940 km across America's heartland. This track captures +//! the iconic journey from Chicago skyscrapers through endless prairies, Texas +//! flatlands, New Mexico mesas, and Arizona/Mojave desert to the Pacific Ocean. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Shoulder, Shoulders, Stage, + Theme, Track, +}; + +// ─── Lane variants ─────────────────────────────────────────────────────────── + +const CHICAGO_LANE_OUT: Lane = Lane { + traffic_density: 0.3, + traffic_cars: CITY_CAR_MIX, + ..CITY_LANE +}; +const CHICAGO_LANE_IN: Lane = Lane { + traffic_density: 0.2, + traffic_cars: CITY_CAR_MIX, + ..CITY_LANE +}; + +// LA traffic: denser than Chicago, you arrive through sprawl +const LA_LANE_OUT: Lane = Lane { + traffic_density: 0.4, + traffic_cars: CITY_CAR_MIX, + ..CITY_LANE +}; +const LA_LANE_IN: Lane = Lane { + traffic_density: 0.3, + traffic_cars: CITY_CAR_MIX, + ..CITY_LANE +}; + +const TOWN_LANE: Lane = Lane { + own_max_speed: 110.0, + traffic_density: 0.3, + traffic_cars: AMERICAN_CAR_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.025, + effects: &[ObstacleEffect::SpeedChange { affect: -0.70 }], + }], + ..CITY_LANE +}; + +const INTERSTATE_OUT: Lane = Lane { + traffic_density: 0.15, + ..INTERSTATE_LANE +}; +const INTERSTATE_IN: Lane = Lane { + own_max_speed: 250.0, // wrong-side speed bonus: 200 → 230 + traffic_density: 0.1, + ..INTERSTATE_LANE +}; + +const PLAINS_OUT: Lane = Lane { + traffic_density: 0.1, + ..PLAINS_LANE +}; +const PLAINS_IN: Lane = Lane { + own_max_speed: 240.0, // wrong-side speed bonus + traffic_density: 0.1, + ..PLAINS_LANE +}; + +// Historic two-lane blacktop — narrow, patchy, low speed +const R66_BLACKTOP: Lane = Lane { + style: theme::LANE_ASPHALT_PATCHY, + own_max_speed: 130.0, + traffic_density: 0.1, + traffic_min_speed: 60.0, + traffic_max_speed: 95.0, + traffic_cars: AMERICAN_CAR_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.03, + effects: &[ObstacleEffect::SpeedChange { affect: -0.25 }], + }, + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.01, + effects: &[ObstacleEffect::SpeedChange { affect: -0.60 }], + }, + ], + ..PLAINS_LANE +}; + +const DESERT_OUT: Lane = Lane { + traffic_density: 0.1, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_SAND_DRIFT, + frequency: 0.040, + effects: &[ObstacleEffect::SpeedChange { affect: -0.40 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.025, + effects: &[ObstacleEffect::SpeedChange { affect: -0.20 }], + }, + Obstacle { + style: theme::OBSTACLE_OIL_SPILL, + frequency: 0.008, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 400 }, + ObstacleEffect::SpeedChange { affect: -0.15 }, + ], + }, + ], + ..DESERT_LANE +}; +const DESERT_IN: Lane = Lane { + own_max_speed: 205.0, // wrong-side speed bonus + traffic_density: 0.1, + ..DESERT_OUT +}; + +const MOJAVE_LANE: Lane = Lane { + own_max_speed: 140.0, + traffic_density: 0.1, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_SAND_DRIFT, + frequency: 0.060, + effects: &[ObstacleEffect::SpeedChange { affect: -0.50 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.018, + effects: &[ObstacleEffect::SpeedChange { affect: -0.85 }], + }, + Obstacle { + style: theme::OBSTACLE_ANIMAL, + frequency: 0.008, + effects: &[ObstacleEffect::Crash], + }, + ], + ..DESERT_LANE +}; + +const MOJAVE_LANE_IN: Lane = Lane { + own_max_speed: 180.0, // wrong-side speed bonus + ..MOJAVE_LANE +}; + +// ─── Shoulder strips ───────────────────────────────────────────────────────── + +const PLAINS_SHOULDERS_L: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_WIRE_FENCE, + repeat: 8, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; +const PLAINS_SHOULDERS_R: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_POLES, + repeat: 12, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; +const DESERT_SHOULDERS_BOTH: &[Shoulder] = &[ + Shoulder { + style: theme::SHOULDER_SAND_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, +]; + +// ─── Stages ────────────────────────────────────────────────────────────────── + +const S01_CHICAGO: Stage = Stage { + name: "Chicago", + description: "Start of Route 66, 1966. Grant Park behind you, Pacific Ocean 3,940 km ahead. City traffic is thick.", + icon: theme::STAGE_METROPOLIS, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[CHICAGO_LANE_OUT, CHICAGO_LANE_OUT], + incoming: &[CHICAGO_LANE_IN, CHICAGO_LANE_IN], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S02_ILLINOIS: Stage = Stage { + name: "Illinois Plains", + description: "Flat farmland as far as the eye can see. Corn, sky, and the occasional grain silo.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 480.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[INTERSTATE_OUT, INTERSTATE_OUT], + incoming: &[INTERSTATE_IN, INTERSTATE_IN], + }, + sceneries: Sceneries { + left: PLAINS_SCENERY, + right: PLAINS_SCENERY, + }, + shoulders: Shoulders { + left: PLAINS_SHOULDERS_L, + right: PLAINS_SHOULDERS_R, + }, + }, +}; + +const S03_ST_LOUIS: Stage = Stage { + name: "St. Louis", + description: "Gateway to the West. The arch glints on the horizon. Traffic clumps at every light.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 60.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[CHICAGO_LANE_OUT], + incoming: &[CHICAGO_LANE_IN], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, + }, +}; + +const S04_MISSOURI: Stage = Stage { + name: "Route 66 / Missouri", + description: "Historic two-lane blacktop through the Ozarks. This is real Route 66 — narrow, patchy, and perfect.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 250.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[R66_BLACKTOP], + incoming: &[R66_BLACKTOP], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: Shoulders { + left: RURAL_SHOULDERS, + right: RURAL_SHOULDERS, + }, + }, +}; + +const S05_OKLAHOMA: Stage = Stage { + name: "Oklahoma Prairie", + description: "Sky as wide as an ocean. Just you, the road, telephone poles, and the occasional tumbleweed. Wrong-side has more room.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 540.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[PLAINS_OUT], + incoming: &[PLAINS_IN], + }, + sceneries: Sceneries { + left: PLAINS_SCENERY, + right: PLAINS_SCENERY, + }, + shoulders: Shoulders { + left: PLAINS_SHOULDERS_L, + right: PLAINS_SHOULDERS_R, + }, + }, +}; + +const S06_TEXAS: Stage = Stage { + name: "Texas Panhandle", + description: "Amarillo by morning. Flat as a pool table, twice as green. Wind turbines spin forever.", + icon: theme::STAGE_WILD_PLAINS, + theme: Theme::Standard, + distance_km: 320.0, + road: Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[INTERSTATE_OUT, INTERSTATE_OUT], + incoming: &[INTERSTATE_IN, INTERSTATE_IN], + }, + sceneries: Sceneries { + left: PLAINS_SCENERY, + right: PLAINS_SCENERY, + }, + shoulders: Shoulders { + left: PLAINS_SHOULDERS_L, + right: PLAINS_SHOULDERS_R, + }, + }, +}; + +const S07_AMARILLO: Stage = Stage { + name: "Amarillo, TX", + description: "Big steak country. Speed bumps through every intersection. Watch the cattle trucks.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 60.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[TOWN_LANE, TOWN_LANE], + incoming: &[TOWN_LANE], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: RURAL_SHOULDERS, + }, + }, +}; + +const S08_NEW_MEXICO: Stage = Stage { + name: "New Mexico Mesas", + description: "Land of Enchantment. Terracotta mesas rise from the desert floor. Sand drifts across the road. The other lane looks emptier.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Desert, + distance_km: 510.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[DESERT_OUT], + incoming: &[DESERT_IN], + }, + sceneries: Sceneries { + left: SOUTHWESTERN_SCENERY, + right: SOUTHWESTERN_SCENERY, + }, + shoulders: Shoulders { + left: DESERT_SHOULDERS_BOTH, + right: DESERT_SHOULDERS_BOTH, + }, + }, +}; + +const S09_ALBUQUERQUE: Stage = Stage { + name: "Albuquerque", + description: "New Mexico's biggest city. Hot air balloons above, green chile on every menu.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Desert, + distance_km: 60.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[TOWN_LANE], + incoming: &[TOWN_LANE], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: SOUTHWESTERN_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: DESERT_SHOULDERS_BOTH, + }, + }, +}; + +const S10_ARIZONA: Stage = Stage { + name: "Arizona Desert", + description: "Saguaro cactus and 45°C heat. Sand drifts across the road endlessly. The oncoming lane looks clear — and faster.", + icon: theme::STAGE_DESERT, + theme: Theme::Desert, + distance_km: 530.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[DESERT_OUT], + incoming: &[DESERT_IN], + }, + sceneries: Sceneries { + left: SOUTHWESTERN_SCENERY, + right: SOUTHWESTERN_SCENERY, + }, + shoulders: Shoulders { + left: DESERT_SHOULDERS_BOTH, + right: DESERT_SHOULDERS_BOTH, + }, + }, +}; + +const S11_MOJAVE: Stage = Stage { + name: "Mojave Desert", + description: "Road buckles in 50°C heat. Craters from washed-out asphalt. Watch for desert animals. Almost there.", + icon: theme::STAGE_DESERT, + theme: Theme::Desert, + distance_km: 270.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MOJAVE_LANE], + incoming: &[MOJAVE_LANE_IN], + }, + sceneries: Sceneries { + left: DESERT_SCENERY, + right: SOUTHWESTERN_SCENERY, + }, + shoulders: Shoulders { + left: DESERT_SHOULDERS_BOTH, + right: DESERT_SHOULDERS_BOTH, + }, + }, +}; + +const S12_SAN_BERNARDINO: Stage = Stage { + name: "San Bernardino", + description: "LA sprawl begins. Traffic thickens suddenly. You can faintly smell the Pacific.", + icon: theme::STAGE_CITY_OUTSKIRTS, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + lanes: Lanes { + outgoing: &[LA_LANE_OUT, LA_LANE_OUT], + incoming: &[LA_LANE_IN, LA_LANE_IN], + }, + sceneries: Sceneries { + left: TOWN_SCENERY, + right: TOWN_SCENERY, + }, + ..ROAD_CITY_2X2 + }, +}; + +const S13_SANTA_MONICA: Stage = Stage { + name: "Santa Monica", + description: "End of the line. Pacific Ocean dead ahead. 3,940 km from Chicago. Route 66 ends here.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Standard, + distance_km: 80.0, + road: Road { + lanes: Lanes { + outgoing: &[LA_LANE_OUT, LA_LANE_OUT], + incoming: &[LA_LANE_IN, LA_LANE_IN], + }, + sceneries: Sceneries { + left: COASTAL_SCENERY, + right: CITY_SCENERY, + }, + ..ROAD_CITY_2X2 + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "Route 66", + author: "claude", + description: "The Mother Road. Chicago to Santa Monica — coast to coast across America's heartland. Starts in a city, fades to endless plains, burns through desert, ends at the Pacific.", + stages: &[ + S01_CHICAGO, + S02_ILLINOIS, + S03_ST_LOUIS, + S04_MISSOURI, + S05_OKLAHOMA, + S06_TEXAS, + S07_AMARILLO, + S08_NEW_MEXICO, + S09_ALBUQUERQUE, + S10_ARIZONA, + S11_MOJAVE, + S12_SAN_BERNARDINO, + S13_SANTA_MONICA, + ], + distance_scale: 0.017, + speed_scale: 2.5, + lives: 3, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/sample.rs b/late-ssh/src/app/arcade/traffic/tracks/sample.rs new file mode 100644 index 00000000..d2426de7 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/sample.rs @@ -0,0 +1,161 @@ +//! Sample track — exercises every stage feature so the rendering and physics +//! code can be smoke-tested with realistic input. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Shoulders, Stage, Theme, + Track, +}; + +// ─── Per-stage road geometries ─────────────────────────────────────────────── + +const CITY_ROAD: Road = Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + incoming: &[CITY_LANE, CITY_LANE], + outgoing: &[CITY_LANE, CITY_LANE], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: CITY_SCENERY, + }, + shoulders: Shoulders { + left: SIDEWALK_SHOULDERS, + right: SIDEWALK_SHOULDERS, + }, +}; + +const HIGHWAY_ROAD: Road = Road { + aspect: RoadAspect { + dividers: HIGHWAY_DIVIDERS, + }, + lanes: Lanes { + incoming: &[HIGHWAY_LANE, HIGHWAY_LANE], + outgoing: &[HIGHWAY_LANE, HIGHWAY_LANE, HIGHWAY_LANE], + }, + sceneries: Sceneries { + left: HIGHWAY_SCENERY, + right: HIGHWAY_SCENERY, + }, + shoulders: Shoulders { + left: HIGHWAY_SHOULDERS, + right: HIGHWAY_SHOULDERS, + }, +}; + +const RURAL_LANE_BUMPY: Lane = Lane { + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.04, + effects: &[ObstacleEffect::SpeedChange { affect: -0.10 }], + }, + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.02, + effects: &[ObstacleEffect::SpeedChange { affect: -0.30 }], + }, + ], + ..RURAL_LANE +}; + +const RURAL_ROAD: Road = Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + incoming: &[RURAL_LANE_BUMPY], + outgoing: &[RURAL_LANE_BUMPY], + }, + sceneries: Sceneries { + left: RURAL_SCENERY, + right: RURAL_SCENERY, + }, + shoulders: Shoulders { + left: RURAL_SHOULDERS, + right: RURAL_SHOULDERS, + }, +}; + +const FOREST_LANE_HAZARD: Lane = Lane { + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_FALLEN_TREE, + frequency: 0.015, + effects: &[ObstacleEffect::Crash], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.05, + effects: &[ObstacleEffect::SpeedChange { affect: -0.40 }], + }, + ], + ..FOREST_LANE +}; + +const FOREST_ROAD: Road = Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + incoming: &[FOREST_LANE_HAZARD], + outgoing: &[FOREST_LANE_HAZARD], + }, + sceneries: Sceneries { + left: FOREST_SCENERY, + right: FOREST_SCENERY, + }, + shoulders: Shoulders { + left: FOREST_SHOULDERS, + right: FOREST_SHOULDERS, + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "Sample", + author: "odd", + description: "A 60-km drive through city, highway, country and snowy forest. \ + Touches every stage feature — good for testing.", + stages: &[ + Stage { + name: "City outskirts", + description: "Leaving town — keep an eye out for traffic lights", + icon: "🏙", + theme: Theme::Standard, + distance_km: 8.0, + road: CITY_ROAD, + }, + Stage { + name: "Open highway", + description: "Smooth asphalt — open it up but watch the trucks", + icon: "🛣", + theme: Theme::Standard, + distance_km: 30.0, + road: HIGHWAY_ROAD, + }, + Stage { + name: "Country backroad", + description: "Patchy tarmac — potholes and bumps slow you down", + icon: "🌾", + theme: Theme::Standard, + distance_km: 12.0, + road: RURAL_ROAD, + }, + Stage { + name: "Snowy forest pass", + description: "Slippery dirt and fallen trees — easy does it", + icon: "🌲", + theme: Theme::Winter, + distance_km: 10.0, + road: FOREST_ROAD, + }, + ], + distance_scale: 0.2, + speed_scale: 2.0, + lives: 3, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/solar_system.rs b/late-ssh/src/app/arcade/traffic/tracks/solar_system.rs new file mode 100644 index 00000000..dadbf975 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/solar_system.rs @@ -0,0 +1,407 @@ +//! Cosmic Highway — a road through the Solar System. +//! +//! From a terrestrial launch pad into low orbit, past the Moon and across +//! the red dust of Mars, through the lethal Asteroid Belt, around Jupiter, +//! through Saturn's rings, and into the lonely deep of space to Pluto's edge. +//! ~85 km displayed, ~10 min. + +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Lane, Lanes, Object, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Scenery, Shoulders, + Stage, Theme, Track, +}; + +// ─── Lane variants ──────────────────────────────────────────────────────────── + +// Climbing into orbit — faster than a highway, traffic thinning fast +const ORBIT_LANE: Lane = Lane { + own_max_speed: 240.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + ..SPACE_LANE +}; + +// Lunar surface — rough, slow, craters everywhere +const MOON_LANE: Lane = Lane { + style: theme::LANE_GRAVEL, + own_max_speed: 140.0, + passive_decel: 2.5, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.045, + effects: &[ObstacleEffect::SpeedChange { affect: -0.80 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.025, + effects: &[ObstacleEffect::SpeedChange { affect: -0.50 }], + }, + ], + ..MOUNTAIN_LANE +}; + +// Mars — cracked dust road, violent sand storms +const MARS_LANE: Lane = Lane { + own_max_speed: 160.0, + passive_decel: 1.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_SAND_DRIFT, + frequency: 0.048, + effects: &[ObstacleEffect::SpeedChange { affect: -0.55 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.018, + effects: &[ObstacleEffect::SpeedChange { affect: -0.78 }], + }, + ], + ..DESERT_LANE +}; + +// Asteroid Belt — dodge or die +const ASTEROID_BELT_LANE: Lane = Lane { + own_max_speed: 160.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_ASTEROID_CHUNK, + frequency: 0.058, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 400 }, + ObstacleEffect::SpeedChange { affect: -0.62 }, + ], + }, + Obstacle { + style: theme::OBSTACLE_METEOR, + frequency: 0.014, + effects: &[ObstacleEffect::Crash], + }, + ], + ..SPACE_LANE +}; + +// Jupiter Approach — gravity assist, fast, light turbulence +const JUPITER_LANE: Lane = Lane { + own_max_speed: 320.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_ASTEROID_CHUNK, + frequency: 0.016, + effects: &[ObstacleEffect::SpeedChange { affect: -0.30 }], + }], + ..SPACE_LANE +}; + +// Saturn's Rings — ring particles like a soft asteroid field +const RING_LANE: Lane = Lane { + own_max_speed: 240.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[Obstacle { + style: theme::OBSTACLE_ASTEROID_CHUNK, + frequency: 0.038, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 250 }, + ObstacleEffect::SpeedChange { affect: -0.45 }, + ], + }], + ..SPACE_LANE +}; + +// Deep Space — no obstacles, pure velocity +const DEEP_SPACE_LANE: Lane = Lane { + own_max_speed: 500.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[], + ..SPACE_LANE +}; + +// Pluto's Edge — frigid, icy, nearly empty +const PLUTO_LANE: Lane = Lane { + style: theme::LANE_GRAVEL, + own_max_speed: 110.0, + passive_decel: 2.0, + traffic_density: 0.1, + traffic_cars: SPACE_SHIP_MIX, + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_ICE_PATCH, + frequency: 0.062, + effects: &[ + ObstacleEffect::BlockWheels { cooldown_ms: 700 }, + ObstacleEffect::SpeedChange { affect: -0.40 }, + ], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.020, + effects: &[ObstacleEffect::SpeedChange { affect: -0.65 }], + }, + ], + ..MOUNTAIN_LANE +}; + +// ─── Local sceneries ───────────────────────────────────────────────────────── + +const MOON_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_ROCK, + incidence: 0.75, + }, + Object { + style: theme::OBJ_STAR, + incidence: 0.20, + }, + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.05, + }, +]; + +const MOON_SCENERY: Scenery = Scenery { + width: 12, + background: theme::SCENERY_SNOW, + objects: MOON_OBJECTS, +}; + +const ASTEROID_SCENERY_OBJECTS: &[Object] = &[ + Object { + style: theme::OBJ_ROCK, + incidence: 0.50, + }, + Object { + style: theme::OBJ_STAR, + incidence: 0.30, + }, + Object { + style: theme::OBJ_PLANET_SMALL, + incidence: 0.10, + }, + Object { + style: theme::OBJ_CRYSTAL_SPIKE, + incidence: 0.10, + }, +]; + +const ASTEROID_SCENERY: Scenery = Scenery { + width: 14, + background: theme::SCENERY_STARFIELD, + objects: ASTEROID_SCENERY_OBJECTS, +}; + +// ─── Stages ────────────────────────────────────────────────────────────────── + +const S01_LAUNCH_PAD: Stage = Stage { + name: "Launch Pad", + description: "T-minus ten seconds. The road leads straight out of the atmosphere. Fuel trucks and support vehicles crowd the launch facility.", + icon: theme::STAGE_CITY, + theme: Theme::Standard, + distance_km: 6.0, + road: Road { + lanes: Lanes { + outgoing: &[CITY_LANE, CITY_LANE], + incoming: &[CITY_LANE, CITY_LANE], + }, + sceneries: Sceneries { + left: CITY_SCENERY, + right: PLAINS_SCENERY, + }, + ..ROAD_CITY_2X2 + }, +}; + +const S02_LOW_EARTH_ORBIT: Stage = Stage { + name: "Low Earth Orbit", + description: "The atmosphere thins. The sky deepens from blue to black. Stars appear. Traffic evaporates to a few brave ships heading the same direction.", + icon: theme::STAGE_HIGHWAY, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + lanes: Lanes { + outgoing: &[ORBIT_LANE, ORBIT_LANE], + incoming: &[ORBIT_LANE, ORBIT_LANE], + }, + sceneries: Sceneries { + left: STARFIELD_SCENERY, + right: STARFIELD_SCENERY, + }, + ..ROAD_SPACE_2X2 + }, +}; + +const S03_THE_MOON: Stage = Stage { + name: "The Moon", + description: "Grey regolith and ancient craters. The lunar surface is unmerciful — deep craters every hundred metres, no atmosphere to slow the fall.", + icon: theme::STAGE_MOON, + theme: Theme::Standard, + distance_km: 10.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MOON_LANE], + incoming: &[MOON_LANE], + }, + sceneries: Sceneries { + left: MOON_SCENERY, + right: MOON_SCENERY, + }, + shoulders: Shoulders { + left: SPACE_BEACON_SHOULDERS, + right: SPACE_BEACON_SHOULDERS, + }, + }, +}; + +const S04_MARS: Stage = Stage { + name: "Martian Highway", + description: "The red planet in all its glory — and fury. Dust storms sweep across the cracked asphalt without warning. The atmosphere is thin and the craters are deep.", + icon: theme::STAGE_PLANET, + theme: Theme::Desert, + distance_km: 12.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[MARS_LANE], + incoming: &[MARS_LANE], + }, + sceneries: Sceneries { + left: SOUTHWESTERN_SCENERY, + right: SOUTHWESTERN_SCENERY, + }, + shoulders: Shoulders { + left: SPACE_BEACON_SHOULDERS, + right: SPACE_BEACON_SHOULDERS, + }, + }, +}; + +const S05_ASTEROID_BELT: Stage = Stage { + name: "Asteroid Belt", + description: "Between Mars and Jupiter: hundreds of millions of rocks from pebbles to mountains. The chunks hit without warning. The meteors don't miss.", + icon: theme::STAGE_WILD_HILLS, + theme: Theme::Standard, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: FOREST_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[ASTEROID_BELT_LANE], + incoming: &[ASTEROID_BELT_LANE], + }, + sceneries: Sceneries { + left: ASTEROID_SCENERY, + right: ASTEROID_SCENERY, + }, + shoulders: Shoulders { + left: SPACE_BEACON_SHOULDERS, + right: SPACE_BEACON_SHOULDERS, + }, + }, +}; + +const S06_JUPITER_APPROACH: Stage = Stage { + name: "Jupiter Approach", + description: "The largest planet in the Solar System looms ahead. Its gravity pulls you faster than any engine could. Light turbulence — occasional ring debris.", + icon: theme::STAGE_PLANET, + theme: Theme::Standard, + distance_km: 10.0, + road: Road { + lanes: Lanes { + outgoing: &[JUPITER_LANE, JUPITER_LANE, JUPITER_LANE], + incoming: &[JUPITER_LANE, JUPITER_LANE], + }, + ..ROAD_SPACE_3X2 + }, +}; + +const S07_SATURNS_RINGS: Stage = Stage { + name: "Saturn's Rings", + description: "A billion chunks of ice and rock, each the size of a car. You're driving through them. The view is spectacular. The physics is not forgiving.", + icon: theme::STAGE_GALAXY, + theme: Theme::Winter, + distance_km: 8.0, + road: Road { + lanes: Lanes { + outgoing: &[RING_LANE, RING_LANE], + incoming: &[RING_LANE, RING_LANE], + }, + ..ROAD_SPACE_2X2 + }, +}; + +const S08_DEEP_SPACE: Stage = Stage { + name: "Deep Space", + description: "Past Saturn, nothing. Absolute silence. Zero obstacles. The Cosmic Highway stretches into darkness at half the speed of a thought. Just drive.", + icon: theme::STAGE_GALAXY, + theme: Theme::Standard, + distance_km: 15.0, + road: Road { + lanes: Lanes { + outgoing: &[DEEP_SPACE_LANE, DEEP_SPACE_LANE, DEEP_SPACE_LANE], + incoming: &[DEEP_SPACE_LANE, DEEP_SPACE_LANE], + }, + ..ROAD_SPACE_3X2 + }, +}; + +const S09_PLUTOS_EDGE: Stage = Stage { + name: "Pluto's Edge", + description: "The last outpost of the Solar System. Surface temperature: -233°C. Ice patches freeze your tyres solid. Beyond this: only stars.", + icon: theme::STAGE_SPECIAL, + theme: Theme::Winter, + distance_km: 8.0, + road: Road { + aspect: RoadAspect { + dividers: RURAL_DIVIDERS, + }, + lanes: Lanes { + outgoing: &[PLUTO_LANE], + incoming: &[PLUTO_LANE], + }, + sceneries: Sceneries { + left: ALPINE_SCENERY, + right: STARFIELD_SCENERY, + }, + shoulders: Shoulders { + left: SPACE_BEACON_SHOULDERS, + right: SPACE_BEACON_SHOULDERS, + }, + }, +}; + +// ─── Track ─────────────────────────────────────────────────────────────────── + +pub const TRACK: Track = Track { + name: "Cosmic Highway", + author: "claude", + description: "From Earth's launch pad to Pluto's frozen edge. The road goes through low orbit, the lunar surface, Mars, the Asteroid Belt, Jupiter, Saturn's rings, and deep space. Watch for meteors.", + stages: &[ + S01_LAUNCH_PAD, + S02_LOW_EARTH_ORBIT, + S03_THE_MOON, + S04_MARS, + S05_ASTEROID_BELT, + S06_JUPITER_APPROACH, + S07_SATURNS_RINGS, + S08_DEEP_SPACE, + S09_PLUTOS_EDGE, + ], + distance_scale: 0.50, + speed_scale: 2.5, + lives: 4, +}; diff --git a/late-ssh/src/app/arcade/traffic/tracks/test.rs b/late-ssh/src/app/arcade/traffic/tracks/test.rs new file mode 100644 index 00000000..cdcd0783 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/tracks/test.rs @@ -0,0 +1,163 @@ +use super::presets::*; +use crate::app::arcade::traffic::theme; +use crate::app::arcade::traffic::track::{ + Car, Lane, Lanes, Object, Obstacle, ObstacleEffect, Road, RoadAspect, Sceneries, Scenery, + Shoulder, Shoulders, Stage, Theme, Track, +}; + +const TEST_LANE: Lane = Lane { + style: theme::LANE_ASPHALT_STANDARD, + own_min_speed: 0.0, + own_max_speed: 150.0, + passive_decel: 0.0, + traffic_min_speed: 40.0, + traffic_max_speed: 90.0, + traffic_density: 0.4, + traffic_cars: &[ + Car { + height: 3, + incidence: 0.4, + }, + Car { + height: 5, + incidence: 0.4, + }, + Car { + height: 1, + incidence: 0.1, + }, + Car { + height: 10, + incidence: 0.1, + }, + ], + obstacles: &[ + Obstacle { + style: theme::OBSTACLE_POTHOLE_SMALL, + frequency: 0.2, + effects: &[ObstacleEffect::SpeedChange { affect: -0.2 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_BIG, + frequency: 0.1, + effects: &[ObstacleEffect::SpeedChange { affect: -0.5 }], + }, + Obstacle { + style: theme::OBSTACLE_SPEED_BUMP, + frequency: 0.01, + effects: &[ObstacleEffect::SpeedChange { affect: -0.5 }], + }, + Obstacle { + style: theme::OBSTACLE_POTHOLE_CRATER, + frequency: 0.05, + effects: &[ObstacleEffect::SpeedChange { affect: -0.9 }], + }, + Obstacle { + style: theme::OBSTACLE_SPIKES, + frequency: 0.01, + effects: &[ObstacleEffect::Crash], + }, + Obstacle { + style: theme::OBSTACLE_FALLEN_TREE, + frequency: 0.01, + effects: &[ObstacleEffect::Crash], + }, + ], +}; + +pub const TRACK: Track = Track { + name: "Test", + author: "odd", + description: "Test track", + stages: &[Stage { + name: "Stage0", + description: "This is stage0 test.", + icon: "🏢", + theme: Theme::Desert, + distance_km: 20.0, + road: Road { + aspect: RoadAspect { + dividers: URBAN_DIVIDERS, + }, + lanes: Lanes { + incoming: &[TEST_LANE, TEST_LANE], + outgoing: &[TEST_LANE, TEST_LANE], + }, + sceneries: Sceneries { + left: Scenery { + width: 16, + background: theme::SCENERY_CONCRETE, + objects: &[Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 1.0, + }], + }, + right: Scenery { + width: 16, + background: theme::SCENERY_CONCRETE, + objects: &[Object { + style: theme::OBJ_BUILDING_HOUSE, + incidence: 1.0, + }], + }, + }, + shoulders: Shoulders { + left: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_COUNTRY_ROAD, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_PALM, + repeat: 10, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + right: &[ + Shoulder { + style: theme::SHOULDER_SOFT_EDGE, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_RIVER, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + Shoulder { + style: theme::SHOULDER_TREE_PINE, + repeat: 10, + }, + Shoulder { + style: theme::SHOULDER_EMPTY, + repeat: 0, + }, + ], + }, + }, + }], + distance_scale: 0.5, + speed_scale: 1.0, + lives: 3, +}; diff --git a/late-ssh/src/app/arcade/traffic/ui.rs b/late-ssh/src/app/arcade/traffic/ui.rs new file mode 100644 index 00000000..84a574b5 --- /dev/null +++ b/late-ssh/src/app/arcade/traffic/ui.rs @@ -0,0 +1,1170 @@ +//! Traffic rendering. Reads geometry, lane configs, scenery, shoulders, and +//! dividers from the active stage on each frame so the picture updates +//! immediately when a stage transition occurs. +//! +//! Layout (left → right): +//! `[minimap] [gap] [left grass] [road] [right grass] [gap] [stats]` + +use ratatui::{ + Frame, + layout::{Alignment, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; +use unicode_width::UnicodeWidthChar; + +use super::state::{Config, Phase, State, TrafficDir, TrafficScreen, hash3}; +use super::theme; +use super::track::{Lanes, Stage, Track}; +use super::tracks::ALL_TRACKS; +use crate::app::arcade::ui::{ + GameBottomBar, centered_rect, draw_game_frame, draw_game_overlay, keys_line, status_line, +}; +use crate::app::common::theme as app_theme; + +const PLAYER_FG: Color = Color::Cyan; +const SAME_DIR_CAR_FG: Color = Color::Rgb(200, 200, 200); +const ONCOMING_CAR_FG: Color = Color::Rgb(230, 60, 60); +const BORDER_FG: Color = Color::Rgb(80, 80, 80); + +const FADE_ROWS: u16 = 4; +const FADE_FACTORS: [f32; 4] = [0.80, 0.50, 0.25, 0.06]; + +const MINI_BG: Color = Color::Rgb(14, 14, 14); +const MINI_BORDER: Color = Color::Rgb(55, 55, 55); +const MINI_DIVIDER: Color = Color::Rgb(70, 58, 20); +const MINI_SAME: Color = Color::Rgb(85, 85, 85); +const MINI_ONCOMING: Color = Color::Rgb(130, 50, 50); +const MINI_PLAYER: Color = Color::Rgb(0, 100, 100); +const MINI_OBSTACLE_SIMPLE: Color = Color::Rgb(200, 200, 0); +const MINI_OBSTACLE_CRASH: Color = Color::Rgb(200, 0, 0); + +// ─── Public entry ──────────────────────────────────────────────────────────── + +pub fn draw_game(frame: &mut Frame, area: Rect, state: &State, show_bottom_bar: bool) { + match state.screen { + TrafficScreen::Picker => draw_picker(frame, area, state, show_bottom_bar), + TrafficScreen::Racing => draw_race(frame, area, state, show_bottom_bar), + } +} + +// ─── Picker ────────────────────────────────────────────────────────────────── + +fn draw_picker(frame: &mut Frame, area: Rect, state: &State, show_bottom_bar: bool) { + let bottom = GameBottomBar { + status: status_line(vec![ + ( + "tracks", + format!("{}", ALL_TRACKS.len()), + app_theme::TEXT_BRIGHT(), + ), + ( + "best", + if state.best_score > 0 { + format_score(state.best_score) + } else { + "—".to_string() + }, + app_theme::AMBER_GLOW(), + ), + ]), + keys: keys_line(vec![("j/k", "select"), ("Enter", "drive"), ("Esc", "exit")]), + tip: Some(crate::app::arcade::ui::tip_line( + "Pick a track. Each track has multiple stages with different roads, themes, and hazards.", + )), + }; + let content_area = draw_game_frame( + frame, + area, + "Shit I'm Late — pick a track", + bottom, + show_bottom_bar, + ); + + if content_area.height < 6 || content_area.width < 40 { + return; + } + + let list_w = (content_area.width as u32 * 35 / 100).max(20) as u16; + let list_area = Rect { + x: content_area.x + 2, + y: content_area.y + 1, + width: list_w, + height: content_area.height.saturating_sub(2), + }; + let detail_area = Rect { + x: content_area.x + 2 + list_w + 2, + y: content_area.y + 1, + width: content_area.width.saturating_sub(list_w + 6), + height: content_area.height.saturating_sub(2), + }; + + let mut lines: Vec> = Vec::with_capacity(ALL_TRACKS.len()); + for (i, t) in ALL_TRACKS.iter().enumerate() { + let selected = i == state.picker_selected_idx; + let prefix = if selected { " ▶ " } else { " " }; + let style = if selected { + Style::default() + .fg(app_theme::AMBER()) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(app_theme::TEXT_BRIGHT()) + }; + lines.push(Line::from(vec![ + Span::styled(prefix.to_string(), style), + Span::styled(t.name.to_string(), style), + ])); + lines.push(Line::from(Span::styled( + format!(" by {}", t.author), + Style::default().fg(app_theme::TEXT_DIM()), + ))); + lines.push(Line::from("")); + } + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), list_area); + + if let Some(track) = ALL_TRACKS.get(state.picker_selected_idx).copied() { + let mut det: Vec> = Vec::new(); + det.push(Line::from(Span::styled( + track.name.to_string(), + Style::default() + .fg(app_theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ))); + det.push(Line::from(Span::styled( + format!("by {}", track.author), + Style::default().fg(app_theme::TEXT_DIM()), + ))); + det.push(Line::from("")); + det.push(Line::from(Span::styled( + track.description.to_string(), + Style::default().fg(app_theme::TEXT_BRIGHT()), + ))); + det.push(Line::from("")); + det.push(Line::from(Span::styled( + format!( + "{} stages — total {:.0} km", + track.stages.len(), + track.total_distance_km() + ), + Style::default().fg(app_theme::SUCCESS()), + ))); + det.push(Line::from("")); + for (i, stage) in track.stages.iter().enumerate() { + det.push(Line::from(vec![ + Span::styled( + format!(" {}. {} ", i + 1, stage.icon), + Style::default().fg(app_theme::TEXT_DIM()), + ), + Span::styled( + stage.name.to_string(), + Style::default().fg(app_theme::TEXT_BRIGHT()), + ), + Span::styled( + format!(" {} {:.0} km", stage.theme.icon(), stage.distance_km), + Style::default().fg(app_theme::TEXT_DIM()), + ), + ])); + } + if let Some(best) = state.best_scores.get(track.name).copied() { + det.push(Line::from("")); + det.push(Line::from(vec![ + Span::styled( + " Best ".to_string(), + Style::default().fg(app_theme::TEXT_DIM()), + ), + Span::styled( + format_score(best), + Style::default() + .fg(app_theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ), + ])); + } + frame.render_widget(Paragraph::new(det).wrap(Wrap { trim: false }), detail_area); + } +} + +// ─── Race ───────────────────────────────────────────────────────────────────── + +struct StageGeom { + road_width: u16, + lane_starts: Vec, + total_lanes: usize, + incoming: usize, + grass_left_w: u16, + grass_right_w: u16, +} + +fn compute_geom(stage: &Stage, road_x: u16) -> StageGeom { + let lanes = &stage.road.lanes; + let total = lanes.total(); + let lane_starts: Vec = (0..total) + .map(|i| road_x + 1 + (i as u16) * (Config::LANE_WIDTH + 1)) + .collect(); + let road_width = 1 + (total as u16) * Config::LANE_WIDTH + total.saturating_sub(1) as u16 + 1; + StageGeom { + road_width, + lane_starts, + total_lanes: total, + incoming: lanes.incoming.len(), + grass_left_w: stage.road.sceneries.left.width as u16, + grass_right_w: stage.road.sceneries.right.width as u16, + } +} + +fn draw_race(frame: &mut Frame, area: Rect, state: &State, show_bottom_bar: bool) { + let track_name = state.track().map(|t| t.name).unwrap_or("(no track)"); + let bottom = GameBottomBar { + status: status_line(vec![ + ( + "speed", + format!("{:.0} km/h", state.player_speed_kmh), + speed_color(state.player_speed_kmh), + ), + ("score", format_score(state.score), app_theme::AMBER_GLOW()), + ( + "progress", + format!("{:.1}%", state.progress_pct()), + app_theme::SUCCESS(), + ), + ]), + keys: keys_line(vec![ + ("w/up", "accel"), + ("s/dn", "brake"), + ("spc", "handbrake"), + ("a/d", "lane"), + ("p", "pause"), + ("r", "restart"), + ("t", "tracks"), + ("Esc", "exit"), + ]), + tip: Some(crate::app::arcade::ui::tip_line( + "Stay on your lane. Each lane has its own min/max — overtaking on the wrong side is risky.", + )), + }; + let content_area = draw_game_frame(frame, area, track_name, bottom, show_bottom_bar); + + let Some(stage) = state.current_stage() else { + return; + }; + let Some(track) = state.track() else { + return; + }; + + let min_w = Config::MIN_TERMINAL_WIDTH_FLOOR; + if content_area.height < Config::MIN_TERMINAL_HEIGHT || content_area.width < min_w { + frame.render_widget( + Paragraph::new(format!( + "Terminal too small — need at least {}×{} (currently: {}x{})", + min_w, + Config::MIN_TERMINAL_HEIGHT, + content_area.width, + content_area.height, + )) + .alignment(Alignment::Center) + .style(Style::default().fg(app_theme::ERROR())), + content_area, + ); + return; + } + + let geom_for_width = compute_geom(stage, 0); + let mini_gap: u16 = 2; + let stats_gap: u16 = 2; + let stats_min: u16 = 30; + let mini_w = mini_width(stage.road.lanes); + let block_w = mini_w + + mini_gap + + geom_for_width.grass_left_w + + geom_for_width.road_width + + geom_for_width.grass_right_w + + stats_gap + + stats_min; + let block_x = content_area.x + content_area.width.saturating_sub(block_w) / 2; + let mini_x = block_x; + let grass_left_x = block_x + mini_w + mini_gap; + let road_x = grass_left_x + geom_for_width.grass_left_w; + let road_y = content_area.y + content_area.height.saturating_sub(Config::VISIBLE_ROWS) / 2; + let road_height = content_area.height.min(Config::VISIBLE_ROWS); + let grass_right_x_end = road_x + geom_for_width.road_width + geom_for_width.grass_right_w; + let stats_x = grass_right_x_end + stats_gap; + let stats_w = (content_area.x + content_area.width).saturating_sub(stats_x); + + let road_area = Rect { + x: road_x, + y: road_y, + width: geom_for_width.road_width, + height: road_height, + }; + let geom = compute_geom(stage, road_x); + + let minimap_rows = (Config::MINIMAP_RANGE_M + / (Config::CAR_HEIGHT_ROWS as f32 * Config::METERS_PER_ROW)) as u16; + let mini_area = Rect { + x: mini_x, + y: road_y, + width: mini_w, + height: minimap_rows.min(road_height), + }; + let stats_area = Rect { + x: stats_x, + y: road_y, + width: stats_w, + height: road_height, + }; + + draw_minimap(frame, mini_area, state, stage); + draw_grass( + frame, + road_area, + grass_left_x, + grass_right_x_end, + state, + stage, + state.scenery_seed, + ); + draw_road(frame, road_area, state, track, stage, &geom); + draw_lane_speed_labels(frame, road_area, stage, &geom); + draw_stats(frame, stats_area, state, track, stage); + + match &state.phase { + Phase::Dead => { + draw_game_overlay( + frame, + road_area, + "CRASH!", + "Press r to restart", + app_theme::ERROR(), + ); + } + Phase::Crashed => { + let sub = format!("{} lives left · any key", state.lives); + draw_game_overlay(frame, road_area, "YOU CRASHED!", &sub, app_theme::ERROR()); + } + Phase::Finished { elapsed_s, score } => { + let mins = (*elapsed_s as u32) / 60; + let secs = (*elapsed_s as u32) % 60; + let time_str = format!("{}:{:02}", mins, secs); + let score_str = format!("Score {}", format_score(*score)); + draw_finish_overlay( + frame, + road_area, + &time_str, + &score_str, + app_theme::SUCCESS(), + ); + } + Phase::Playing if state.is_paused => { + draw_game_overlay( + frame, + road_area, + "PAUSED", + "Press p to resume", + app_theme::AMBER(), + ); + } + _ => {} + } +} + +// ─── Minimap ───────────────────────────────────────────────────────────────── + +fn mini_width(lanes: Lanes) -> u16 { + 1 + lanes.incoming.len() as u16 + 1 + lanes.outgoing.len() as u16 + 1 +} + +fn mini_lane_offset(stage: &Stage, lane_idx: usize) -> u16 { + let in_n = stage.road.lanes.incoming.len(); + let base = 1 + lane_idx as u16; + if lane_idx >= in_n { base + 1 } else { base } +} + +fn draw_minimap(frame: &mut Frame, area: Rect, state: &State, stage: &Stage) { + let scale_m = Config::CAR_HEIGHT_ROWS as f32 * Config::METERS_PER_ROW; + let rows = ((Config::MINIMAP_RANGE_M / scale_m) as u16).min(area.height); + let buf = frame.buffer_mut(); + let in_n = stage.road.lanes.incoming.len() as u16; + let mini_w = mini_width(stage.road.lanes); + let divider_x = area.x + 1 + in_n; + let right_border_x = area.x + mini_w - 1; + + for mr in 0..rows { + let sy = area.y + mr; + for x in area.x..area.x + mini_w { + if let Some(c) = buf.cell_mut((x, sy)) { + if x == area.x || x == right_border_x { + c.set_symbol("│").set_fg(MINI_BORDER).set_bg(MINI_BG); + } else if x == divider_x { + c.set_symbol("·").set_fg(MINI_DIVIDER).set_bg(MINI_BG); + } else { + c.set_symbol(" ").set_fg(MINI_BG).set_bg(MINI_BG); + } + } + } + } + + for car in &state.ai_cars { + let ahead_m = car.pos_m - state.player_pos_m; + if ahead_m < 0.0 { + continue; + } + let idx = (ahead_m / scale_m) as u16; + if idx >= rows { + continue; + } + let sy = area.y + (rows - 1) - idx; + let x = area.x + mini_lane_offset(stage, car.lane_idx); + let fg = match car.direction { + TrafficDir::Same => MINI_SAME, + TrafficDir::Oncoming => MINI_ONCOMING, + }; + if let Some(c) = buf.cell_mut((x, sy)) { + c.set_symbol("▪").set_fg(fg).set_bg(MINI_BG); + } + } + + for obs in &state.obstacles { + let ahead_m = obs.pos_m - state.player_pos_m; + if ahead_m < 0.0 { + continue; + } + let idx = (ahead_m / scale_m) as u16; + if idx >= rows { + continue; + } + let sy = area.y + (rows - 1) - idx; + let x = area.x + mini_lane_offset(stage, obs.lane_idx); + if let Some(c) = buf.cell_mut((x, sy)) { + let fg = if obs.crash { + MINI_OBSTACLE_CRASH + } else { + MINI_OBSTACLE_SIMPLE + }; + c.set_symbol("!").set_fg(fg).set_bg(MINI_BG); + } + } + + let x = area.x + mini_lane_offset(stage, state.player_lane_idx); + if let Some(c) = buf.cell_mut((x, area.y + rows.saturating_sub(1))) { + c.set_symbol("▲").set_fg(MINI_PLAYER).set_bg(MINI_BG); + } +} + +// ─── Grass / shoulders ─────────────────────────────────────────────────────── + +fn darken(c: Color, factor: f32) -> Color { + match c { + Color::Rgb(r, g, b) => Color::Rgb( + (r as f32 * factor) as u8, + (g as f32 * factor) as u8, + (b as f32 * factor) as u8, + ), + other => other, + } +} + +fn draw_grass( + frame: &mut Frame, + road_area: Rect, + grass_left_x: u16, + grass_right_end_x: u16, + state: &State, + stage: &Stage, + scenery_seed: u64, +) { + let buf = frame.buffer_mut(); + let track_base = (state.player_pos_m / Config::METERS_PER_ROW) as i32; + let left_bg = (stage.road.sceneries.left.background.bg)(stage.theme); + let right_bg = (stage.road.sceneries.right.background.bg)(stage.theme); + let road_right = road_area.x + road_area.width; + + for r in 0..Config::VISIBLE_ROWS { + let screen_y = road_area.y + r; + if screen_y >= road_area.y + road_area.height { + break; + } + let ri = r as i32; + let track_row = track_base - (ri - Config::PLAYER_TOP_ROW as i32); + + let bottom_fade_start = road_area.height.saturating_sub(FADE_ROWS); + let fade: Option = if r < FADE_ROWS { + Some(FADE_FACTORS[(FADE_ROWS - 1 - r) as usize]) + } else if r >= bottom_fade_start { + Some(FADE_FACTORS[(r - bottom_fade_start) as usize]) + } else { + None + }; + let lbg = fade.map_or(left_bg, |f| darken(left_bg, f)); + let rbg = fade.map_or(right_bg, |f| darken(right_bg, f)); + + for x in grass_left_x..road_area.x { + if let Some(c) = buf.cell_mut((x, screen_y)) { + c.set_symbol(" ").set_bg(lbg); + } + } + for x in road_right..grass_right_end_x { + if let Some(c) = buf.cell_mut((x, screen_y)) { + c.set_symbol(" ").set_bg(rbg); + } + } + + draw_scenery_side( + buf, + stage.road.sceneries.left.objects, + grass_left_x..road_area.x, + screen_y, + track_row, + stage.theme, + lbg, + stage.road.sceneries.left.width, + stage.road.shoulders.left.len() as u16, + true, + fade, + scenery_seed, + ); + draw_scenery_side( + buf, + stage.road.sceneries.right.objects, + road_right..grass_right_end_x, + screen_y, + track_row, + stage.theme, + rbg, + stage.road.sceneries.right.width, + stage.road.shoulders.right.len() as u16, + false, + fade, + scenery_seed, + ); + + draw_shoulders_side( + buf, + stage.road.shoulders.left, + road_area.x, + screen_y, + track_row, + stage.theme, + lbg, + true, + fade, + ); + draw_shoulders_side( + buf, + stage.road.shoulders.right, + road_right - 1, + screen_y, + track_row, + stage.theme, + rbg, + false, + fade, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_scenery_side( + buf: &mut ratatui::buffer::Buffer, + objects: &[super::track::Object], + x_range: std::ops::Range, + screen_y: u16, + track_row: i32, + theme_id: super::track::Theme, + fallback_bg: Color, + band_width: u8, + shoulder_width: u16, + left_side: bool, + fade: Option, + run_salt: u64, +) { + if objects.is_empty() { + return; + } + + const STRIDE: u16 = 4; + const MAX_HEIGHT: i64 = 6; + const ROW_STRIDE: i64 = 7; + const PLACEMENT_GATE: u64 = 2; + + let band_w = band_width as u16; + if band_w <= shoulder_width { + return; + } + let inner_count = band_w - shoulder_width; + + for d in 0..inner_count { + if d % STRIDE != 1 { + continue; + } + let col_salt = hash3(run_salt as i64, d as i64, if left_side { 0 } else { 1 }) as i64; + for h in 0..MAX_HEIGHT { + let anchor_row = track_row as i64 - h; + if (anchor_row + col_salt).rem_euclid(ROW_STRIDE) != 0 { + continue; + } + let seed = hash3( + anchor_row ^ run_salt as i64, + d as i64, + if left_side { 0 } else { 1 }, + ); + if !seed.is_multiple_of(PLACEMENT_GATE) { + continue; + } + let obj = pick_object(objects, seed); + let sprite = (obj.style.sprite)(theme_id); + let sprite_h = sprite.glyphs.len() as i64; + if h >= sprite_h { + break; + } + let sprite_w = sprite.width as u16; + if d + sprite_w > inner_count { + break; + } + let row_idx = (sprite_h - 1 - h) as usize; + let row_glyphs = sprite.glyphs[row_idx]; + let bottom_row = h == 0; + let trunk_fg = theme::trunk_color(theme_id); + for (w_idx, glyph) in row_glyphs.iter().enumerate() { + if *glyph == " " { + continue; + } + let xx = if left_side { + x_range.start.saturating_add(d + w_idx as u16) + } else { + x_range.end.saturating_sub(d + sprite_w - w_idx as u16) + }; + if xx < x_range.start || xx >= x_range.end { + continue; + } + let raw_fg = if bottom_row && obj.style.has_trunk { + trunk_fg + } else { + sprite.fg + }; + let fg = fade.map_or(raw_fg, |f| darken(raw_fg, f)); + if let Some(c) = buf.cell_mut((xx, screen_y)) { + c.set_symbol(glyph).set_fg(fg).set_bg(fallback_bg); + } + } + break; + } + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_shoulders_side( + buf: &mut ratatui::buffer::Buffer, + shoulders: &[super::track::Shoulder], + base_x: u16, + screen_y: u16, + track_row: i32, + theme_id: super::track::Theme, + fallback_bg: Color, + left_side: bool, + fade: Option, +) { + for (i, sh) in shoulders.iter().enumerate() { + let x = if left_side { + base_x.saturating_sub(i as u16 + 1) + } else { + base_x + i as u16 + 1 + }; + let cell = (sh.style.cell)(theme_id, track_row, sh.repeat, fallback_bg); + let fg = fade.map_or(cell.fg, |f| darken(cell.fg, f)); + let bg = fade.map_or(cell.bg, |f| darken(cell.bg, f)); + if let Some(c) = buf.cell_mut((x, screen_y)) { + c.set_symbol(cell.sym).set_fg(fg).set_bg(bg); + } + } +} + +fn pick_object(objects: &[super::track::Object], seed: u64) -> &super::track::Object { + let total: f32 = objects.iter().map(|o| o.incidence).sum(); + if total <= 0.0 { + return &objects[0]; + } + let r = ((seed % 10_000) as f32 / 10_000.0) * total; + let mut acc = 0.0; + for o in objects { + acc += o.incidence; + if r < acc { + return o; + } + } + &objects[objects.len() - 1] +} + +// ─── Road ──────────────────────────────────────────────────────────────────── + +fn lane_separator_x(geom: &StageGeom, lane_idx: usize) -> Option { + if lane_idx + 1 >= geom.total_lanes { + return None; + } + Some(geom.lane_starts[lane_idx] + Config::LANE_WIDTH) +} + +fn player_body_x_start(geom: &StageGeom, lane_f: f32) -> u16 { + let total = geom.total_lanes as i32; + let floor_i = (lane_f.floor() as i32).clamp(0, (total - 1).max(0)); + let frac = (lane_f - floor_i as f32).clamp(0.0, 1.0); + let a = geom.lane_starts[floor_i as usize] as f32 + 1.0; + let next_i = (floor_i + 1).min(total - 1).max(0); + let b = geom.lane_starts[next_i as usize] as f32 + 1.0; + (a + (b - a) * frac).round() as u16 +} + +fn is_car_col(col: u16) -> bool { + (1..=3).contains(&col) +} + +const SEP_LINE_FG: Color = Color::Rgb(160, 160, 160); +const SEP_LABEL_FG: Color = Color::Rgb(240, 220, 140); +const SEP_BG: Color = Color::Rgb(10, 10, 10); + +fn draw_road( + frame: &mut Frame, + area: Rect, + state: &State, + track: &Track, + stage: &Stage, + geom: &StageGeom, +) { + let buf = frame.buffer_mut(); + let lanes = stage.road.lanes; + let theme_id = stage.theme; + let player_track_row = (state.player_pos_m / Config::METERS_PER_ROW) as i32; + + // Pre-compute which screen rows contain a stage separator and which stage + // starts there. Stage 0 separator is at track pos 0 (pre-stage is before that). + let scale = state.distance_scale(); + let mut sep_at: Vec<(i32, usize)> = Vec::with_capacity(track.stages.len()); + let mut sep_pos_m = 0.0f32; + for (idx, stg) in track.stages.iter().enumerate() { + let sep_row = state.track_to_screen_row(sep_pos_m); + sep_at.push((sep_row, idx)); + sep_pos_m += stg.distance_km * 1000.0 * scale; + } + + for r in 0..Config::VISIBLE_ROWS { + let screen_y = area.y + r; + if screen_y >= area.y + area.height { + break; + } + let ri = r as i32; + let track_row = player_track_row - (ri - Config::PLAYER_TOP_ROW as i32); + + let bottom_fade_start = area.height.saturating_sub(FADE_ROWS); + let fade_idx: Option = if r < FADE_ROWS { + Some((FADE_ROWS - 1 - r) as usize) + } else if r >= bottom_fade_start { + Some((r - bottom_fade_start) as usize) + } else { + None + }; + + if let Some(cell) = buf.cell_mut((area.x, screen_y)) { + cell.set_symbol("│") + .set_fg(BORDER_FG) + .set_bg(Color::Rgb(0, 0, 0)); + } + + for lane_idx in 0..geom.total_lanes { + let lane_cfg = match lanes.get(lane_idx) { + Some(l) => l, + None => continue, + }; + let bg = (lane_cfg.style.bg)(theme_id); + let lane_x_start = geom.lane_starts[lane_idx]; + let car_hit = state.ai_cars.iter().find(|c| { + c.lane_idx == lane_idx + && ri >= car_top_row(state, c) + && ri < car_top_row(state, c) + c.height_rows as i32 + }); + let obstacle_hit = state + .obstacles + .iter() + .find(|o| o.lane_idx == lane_idx && state.track_to_screen_row(o.pos_m) == ri); + + for col in 0..Config::LANE_WIDTH { + let screen_x = lane_x_start + col; + if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { + if let Some(car) = car_hit + && is_car_col(col) + { + let fg = match car.direction { + TrafficDir::Same => SAME_DIR_CAR_FG, + TrafficDir::Oncoming => ONCOMING_CAR_FG, + }; + cell.set_symbol("█").set_fg(fg).set_bg(bg); + } else if let Some(obs) = obstacle_hit { + let (glyphs, fg) = obs.style.glyphs; + if is_car_col(col) { + let body_col = (col as i32 - 1).clamp(0, 2) as usize; + cell.set_symbol(glyphs[body_col]).set_fg(fg).set_bg(bg); + } else { + let cell_v = (lane_cfg.style.cell)(theme_id, track_row, col); + cell.set_symbol(cell_v.sym) + .set_fg(cell_v.fg) + .set_bg(cell_v.bg); + } + } else { + let cell_v = (lane_cfg.style.cell)(theme_id, track_row, col); + cell.set_symbol(cell_v.sym) + .set_fg(cell_v.fg) + .set_bg(cell_v.bg); + } + } + } + } + + for lane_idx in 0..geom.total_lanes { + let Some(sep_x) = lane_separator_x(geom, lane_idx) else { + continue; + }; + let next_dir_same = (lane_idx + 1 < geom.incoming) || (lane_idx >= geom.incoming); + let div_style = if next_dir_same { + stage.road.aspect.dividers.lane + } else { + stage.road.aspect.dividers.primary + }; + let nb_bg = match lanes.get(lane_idx) { + Some(l) => (l.style.bg)(theme_id), + None => Color::Rgb(0, 0, 0), + }; + let cell_v = (div_style.cell)(track_row, nb_bg); + if let Some(cell) = buf.cell_mut((sep_x, screen_y)) { + cell.set_symbol(cell_v.sym) + .set_fg(cell_v.fg) + .set_bg(cell_v.bg); + } + } + + let right_border_x = area.x + geom.road_width - 1; + if let Some(cell) = buf.cell_mut((right_border_x, screen_y)) { + cell.set_symbol("│") + .set_fg(BORDER_FG) + .set_bg(Color::Rgb(0, 0, 0)); + } + + // Stage separator: overwrite this row with a horizontal rule + stage label. + if let Some(&(_, sep_stage_idx)) = sep_at.iter().find(|&&(row, _)| row == ri) { + let sep_stage = &track.stages[sep_stage_idx]; + let label: String = format!(" {} {} ", sep_stage.icon, sep_stage.name); + let label_w: usize = label + .chars() + .map(|c| UnicodeWidthChar::width(c).unwrap_or(1)) + .sum(); + let inner_w = (geom.road_width as usize).saturating_sub(2); + let pad_left = inner_w.saturating_sub(label_w) / 2; + let mut x_inner = 0usize; + let mut label_chars = label.chars(); + let mut in_label_idx = 0usize; + while x_inner < inner_w { + let screen_x = area.x + 1 + x_inner as u16; + let in_label = x_inner >= pad_left && in_label_idx < label_w; + if in_label { + if let Some(ch) = label_chars.next() { + let w = UnicodeWidthChar::width(ch).unwrap_or(1); + let mut tmp = [0u8; 4]; + let sym = ch.encode_utf8(&mut tmp); + if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { + cell.set_symbol(sym).set_fg(SEP_LABEL_FG).set_bg(SEP_BG); + } + if w == 2 + && let Some(cell) = buf.cell_mut((screen_x + 1, screen_y)) + { + cell.set_symbol(" ").set_fg(SEP_LABEL_FG).set_bg(SEP_BG); + } + in_label_idx += w; + x_inner += w; + } else { + if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { + cell.set_symbol("─").set_fg(SEP_LINE_FG).set_bg(SEP_BG); + } + x_inner += 1; + } + } else { + if let Some(cell) = buf.cell_mut((screen_x, screen_y)) { + cell.set_symbol("─").set_fg(SEP_LINE_FG).set_bg(SEP_BG); + } + x_inner += 1; + } + } + } + + let p_top = Config::PLAYER_TOP_ROW as i32; + let p_h = Config::CAR_HEIGHT_ROWS as i32; + if ri >= p_top && ri < p_top + p_h && state.player_visible() { + let body_x = player_body_x_start(geom, state.player_lane_display); + let bg = lanes + .get(state.player_lane_idx) + .map(|l| (l.style.bg)(theme_id)) + .unwrap_or(Color::Rgb(0, 0, 0)); + for col in 0..3 { + if let Some(cell) = buf.cell_mut((body_x + col, screen_y)) { + cell.set_symbol("█").set_fg(PLAYER_FG).set_bg(bg); + } + } + } + + if let Some(fi) = fade_idx { + let factor = FADE_FACTORS[fi]; + for x in 0..geom.road_width { + if let Some(cell) = buf.cell_mut((area.x + x, screen_y)) { + let new_bg = darken(cell.bg, factor); + let new_fg = darken(cell.fg, factor); + cell.set_bg(new_bg).set_fg(new_fg); + } + } + } + } +} + +fn car_top_row(state: &State, c: &super::state::AiCar) -> i32 { + let center = state.track_to_screen_row(c.pos_m); + center - (c.height_rows as i32) / 2 +} + +// ─── Stats panel ───────────────────────────────────────────────────────────── + +fn draw_stats(frame: &mut Frame, area: Rect, state: &State, track: &Track, stage: &Stage) { + if area.width < 18 || area.height < 8 { + return; + } + + let displayed_km = state.displayed_km_total(); + let total_km = state.track_total_km(); + let stage_km = state.displayed_km_stage(); + let stage_total_km = state.current_stage_km(); + + let dim = |s: &'static str| Span::styled(s, Style::default().fg(app_theme::TEXT_DIM())); + let bright = |s: String| Span::styled(s, Style::default().fg(app_theme::TEXT_BRIGHT())); + + let mut lines: Vec> = vec![ + Line::from(""), + Line::from(Span::styled( + format!(" {}", track.name), + Style::default() + .fg(app_theme::AMBER()) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + format!(" by {}", track.author), + Style::default().fg(app_theme::TEXT_DIM()), + )), + Line::from(""), + Line::from(vec![ + Span::styled( + format!(" {} ", stage.icon), + Style::default().fg(app_theme::TEXT_BRIGHT()), + ), + Span::styled( + stage.name.to_string(), + Style::default() + .fg(app_theme::TEXT_BRIGHT()) + .add_modifier(Modifier::BOLD), + ), + ]), + Line::from(vec![ + Span::styled( + format!(" {} ", stage.theme.icon()), + Style::default().fg(app_theme::TEXT_BRIGHT()), + ), + dim("Scenery: "), + bright(stage.theme.name().to_string()), + ]), + Line::from(Span::styled( + format!(" {}", stage.description), + Style::default().fg(app_theme::TEXT_DIM()), + )), + Line::from(""), + Line::from(vec![ + dim(" Speed "), + Span::styled( + format!("{:.0} km/h", state.player_speed_kmh), + Style::default() + .fg(speed_color(state.player_speed_kmh)) + .add_modifier(Modifier::BOLD), + ), + ]), + ]; + + if let Some(lane) = stage.road.lanes.get(state.player_lane_idx) { + lines.push(Line::from(vec![ + dim(" lane min/max "), + bright(format!( + "{:.0}/{:.0}", + lane.own_min_speed, lane.own_max_speed + )), + ])); + } + let max_lives = state.track().map(|t| t.lives).unwrap_or(state.lives); + let lost = max_lives.saturating_sub(state.lives); + lines.push(Line::from(vec![ + dim(" Lives "), + Span::styled( + "♥ ".repeat(state.lives as usize), + Style::default() + .fg(app_theme::ERROR()) + .add_modifier(Modifier::BOLD), + ), + Span::styled( + "♡ ".repeat(lost as usize), + Style::default().fg(app_theme::TEXT_DIM()), + ), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + dim(" Stage "), + bright(format!("{:.1} / {:.0} km", stage_km, stage_total_km)), + ])); + lines.push(Line::from(vec![ + dim(" Track "), + bright(format!("{:.1} / {:.0} km", displayed_km, total_km)), + ])); + lines.push(Line::from(vec![ + dim(" Time "), + bright(state.elapsed_formatted()), + ])); + lines.push(Line::from("")); + lines.push(Line::from(vec![ + dim(" Score "), + Span::styled( + format_score(state.score), + Style::default() + .fg(app_theme::AMBER_GLOW()) + .add_modifier(Modifier::BOLD), + ), + ])); + if let Some(best) = state.best_scores.get(track.name).copied() { + lines.push(Line::from(vec![ + dim(" Best "), + Span::styled( + format_score(best), + Style::default().fg(app_theme::SUCCESS()), + ), + ])); + } + + lines.push(Line::from("")); + let bar_width = (area.width as usize).saturating_sub(4).min(20); + let pct = state.progress_pct(); + let filled = ((pct / 100.0) * bar_width as f32) as usize; + let empty = bar_width.saturating_sub(filled); + lines.push(Line::from(Span::styled( + format!(" [{}{}] {:.0}%", "█".repeat(filled), "░".repeat(empty), pct), + Style::default().fg(app_theme::AMBER()), + ))); + + let km_left = (stage_total_km - stage_km).max(0.0); + lines.push(Line::from(vec![ + dim(" Now "), + bright(format!("{} ({:.1} km left)", stage.name, km_left)), + ])); + if let Some(next) = track.stages.get(state.current_stage_idx + 1) { + lines.push(Line::from(vec![ + dim(" Next "), + Span::styled( + next.name.to_string(), + Style::default().fg(app_theme::TEXT_DIM()), + ), + ])); + } + + if !state.recent_effects.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(dim(" Recent"))); + for eff in &state.recent_effects { + lines.push(Line::from(Span::styled( + format!(" {}", eff.label), + Style::default().fg(app_theme::ERROR()), + ))); + } + } + + frame.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), area); +} + +fn speed_color(kmh: f32) -> Color { + if kmh >= 150.0 { + Color::Red + } else if kmh >= 100.0 { + Color::Yellow + } else if kmh >= 50.0 { + app_theme::SUCCESS() + } else { + app_theme::TEXT_DIM() + } +} + +fn draw_lane_speed_labels(frame: &mut Frame, area: Rect, stage: &Stage, geom: &StageGeom) { + let screen_y = area.y + area.height.saturating_sub(1); + if screen_y < area.y { + return; + } + let buf = frame.buffer_mut(); + for lane_idx in 0..geom.total_lanes { + let Some(lane) = stage.road.lanes.get(lane_idx) else { + continue; + }; + let speed = lane.own_max_speed as u16; + let label = format!("{:>3}", speed); + let lane_x = geom.lane_starts[lane_idx]; + for (i, ch) in label.chars().enumerate() { + if let Some(cell) = buf.cell_mut((lane_x + 1 + i as u16, screen_y)) { + let mut tmp = [0u8; 4]; + cell.set_symbol(ch.encode_utf8(&mut tmp)) + .set_fg(Color::Rgb(160, 160, 100)) + .set_bg(Color::Rgb(0, 0, 0)); + } + } + } +} + +fn draw_finish_overlay( + frame: &mut Frame, + area: Rect, + time_str: &str, + score_str: &str, + color: Color, +) { + let overlay_area = centered_rect(area, 36.min(area.width), 5.min(area.height)); + let overlay = Paragraph::new(vec![ + Line::from(Span::styled( + " FINISHED! ", + Style::default() + .bg(color) + .fg(ratatui::style::Color::Reset) + .add_modifier(Modifier::BOLD), + )), + Line::from(Span::styled( + time_str.to_string(), + Style::default().fg(app_theme::TEXT_DIM()), + )), + Line::from(Span::styled( + score_str.to_string(), + Style::default().fg(app_theme::TEXT_DIM()), + )), + ]) + .alignment(Alignment::Center) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(color)), + ); + frame.render_widget(Clear, overlay_area); + frame.render_widget(overlay, overlay_area); +} + +fn format_score(s: i64) -> String { + let s = s.to_string(); + let mut out = String::with_capacity(s.len() + s.len() / 3); + for (i, ch) in s.chars().rev().enumerate() { + if i > 0 && i % 3 == 0 { + out.push(','); + } + out.push(ch); + } + out.chars().rev().collect() +} diff --git a/late-ssh/src/app/arcade/ui.rs b/late-ssh/src/app/arcade/ui.rs index 5186b51a..444a7453 100644 --- a/late-ssh/src/app/arcade/ui.rs +++ b/late-ssh/src/app/arcade/ui.rs @@ -17,7 +17,7 @@ use crate::app::{ GAME_SELECTION_NES_SQUIRREL_DOMINO, GAME_SELECTION_NES_THWAITE, GAME_SELECTION_NES_ZAP_RUDER, GAME_SELECTION_NONOGRAMS, GAME_SELECTION_RUBIKS_CUBE, GAME_SELECTION_SNAKE, GAME_SELECTION_SOLITAIRE, GAME_SELECTION_SUDOKU, - GAME_SELECTION_TETRIS, + GAME_SELECTION_TETRIS, GAME_SELECTION_TRAFFIC, }, }; @@ -192,6 +192,7 @@ pub fn game_title(selection: usize) -> &'static str { GAME_SELECTION_SOLITAIRE => "Solitaire", GAME_SELECTION_SNAKE => "Snake", GAME_SELECTION_RUBIKS_CUBE => "Rubik's Cube", + GAME_SELECTION_TRAFFIC => "Traffic", _ => "The Arcade", } } @@ -204,6 +205,7 @@ pub struct ArcadeHubView<'a> { pub snake_state: &'a super::snake::state::State, pub rubiks_cube_state: &'a super::rubiks_cube::state::State, pub le_word_state: &'a super::le_word::state::State, + pub traffic_state: &'a super::traffic::state::State, pub nes_cabinet_state: &'a super::nes_cabinet::state::State, pub sudoku_state: &'a super::sudoku::state::State, pub nonogram_state: &'a super::nonogram::state::State, @@ -229,6 +231,9 @@ pub fn draw_arcade_hub(frame: &mut Frame, area: Rect, view: &ArcadeHubView<'_>) } else if view.game_selection == GAME_SELECTION_SNAKE { super::snake::ui::draw_game(frame, area, view.snake_state, show_bottom_bar); return; + } else if view.game_selection == GAME_SELECTION_TRAFFIC { + super::traffic::ui::draw_game(frame, area, view.traffic_state, show_bottom_bar); + return; } else if view.game_selection == GAME_SELECTION_RUBIKS_CUBE { super::rubiks_cube::ui::draw_game(frame, area, view.rubiks_cube_state, show_bottom_bar); return; @@ -398,6 +403,18 @@ fn draw_header(frame: &mut Frame, area: Rect, selection: usize) { "Classic Snake game, eat, grow and survive!", " ", ), + GAME_SELECTION_TRAFFIC => ( + vec![ + r#" ████████╗██████╗ █████╗ ███████╗███████╗██╗ ██████╗"#, + r#" ╚══██╔══╝██╔══██╗██╔══██╗██╔════╝██╔════╝██║██╔════╝"#, + r#" ██║ ██████╔╝███████║█████╗ █████╗ ██║██║ "#, + r#" ██║ ██╔══██╗██╔══██║██╔══╝ ██╔══╝ ██║██║ "#, + r#" ██║ ██║ ██║██║ ██║██║ ██║ ██║╚██████╗"#, + r#" ╚═╝ ╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═════╝"#, + ], + "You're LATE and stuck in TRAFFIC. Overtake or crash.", + " ", + ), selection if super::input::is_nes_selection(selection) => ( vec![ r#" ███╗ ██╗███████╗███████╗"#, @@ -485,6 +502,16 @@ fn draw_game_list(frame: &mut Frame, area: Rect, view: &ArcadeHubView<'_>) { "Eat grow and avoid danger. Speed rises as you survive.", format!("Best {}", view.snake_state.best_score), ), + ( + GAME_SELECTION_TRAFFIC, + "Traffic", + "You're LATE and stuck in TRAFFIC. Overtake or crash.", + if view.traffic_state.best_score > 0 { + format!("Best {:>11}", view.traffic_state.best_score) + } else { + "No runs yet".to_string() + }, + ), ] { draw_game_entry( &mut lines, diff --git a/late-ssh/src/app/hub/leaderboard.rs b/late-ssh/src/app/hub/leaderboard.rs index 591c0b25..494218d9 100644 --- a/late-ssh/src/app/hub/leaderboard.rs +++ b/late-ssh/src/app/hub/leaderboard.rs @@ -27,7 +27,7 @@ pub fn draw(frame: &mut Frame, area: Rect, data: &LeaderboardData, user_id: Uuid .split(area); let (top_panels, top_gaps) = split_with_dividers(rows[0], 2); - let (score_panels, score_gaps) = split_with_dividers(rows[2], 3); + let (score_panels, score_gaps) = split_with_dividers(rows[2], 4); draw_top_row(frame, &top_panels, data, user_id); draw_score_row(frame, &score_panels, data, user_id); @@ -165,6 +165,14 @@ fn draw_score_row(frame: &mut Frame, panels: &[Rect], data: &LeaderboardData, us high_scores_for(data, "Snake"), user_id, ); + draw_score_panel( + frame, + panels[3], + "Traffic", + &data.monthly_traffic_high_scores, + high_scores_for(data, "Traffic"), + user_id, + ); } fn high_scores_for<'a>(data: &'a LeaderboardData, game: &str) -> Vec<&'a HighScoreEntry> { diff --git a/late-ssh/src/app/render.rs b/late-ssh/src/app/render.rs index 4f3213c9..d7141122 100644 --- a/late-ssh/src/app/render.rs +++ b/late-ssh/src/app/render.rs @@ -168,6 +168,7 @@ struct DrawContext<'a> { snake_state: &'a crate::app::arcade::snake::state::State, rubiks_cube_state: &'a crate::app::arcade::rubiks_cube::state::State, le_word_state: &'a crate::app::arcade::le_word::state::State, + traffic_state: &'a crate::app::arcade::traffic::state::State, sudoku_state: &'a crate::app::arcade::sudoku::state::State, nonogram_state: &'a crate::app::arcade::nonogram::state::State, solitaire_state: &'a crate::app::arcade::solitaire::state::State, @@ -847,6 +848,7 @@ impl App { snake_state: &self.snake_state, rubiks_cube_state: &self.rubiks_cube_state, le_word_state: &self.le_word_state, + traffic_state: &self.traffic_state, sudoku_state: &self.sudoku_state, nonogram_state: &self.nonogram_state, solitaire_state: &self.solitaire_state, @@ -1243,6 +1245,7 @@ impl App { snake_state: ctx.snake_state, rubiks_cube_state: ctx.rubiks_cube_state, le_word_state: ctx.le_word_state, + traffic_state: ctx.traffic_state, sudoku_state: ctx.sudoku_state, nonogram_state: ctx.nonogram_state, solitaire_state: ctx.solitaire_state, diff --git a/late-ssh/src/app/state.rs b/late-ssh/src/app/state.rs index 128d80a7..f8c0ab4a 100644 --- a/late-ssh/src/app/state.rs +++ b/late-ssh/src/app/state.rs @@ -84,17 +84,18 @@ pub(crate) const GAME_SELECTION_NONOGRAMS: usize = 4; pub(crate) const GAME_SELECTION_MINESWEEPER: usize = 5; pub(crate) const GAME_SELECTION_SOLITAIRE: usize = 6; pub(crate) const GAME_SELECTION_SNAKE: usize = 7; -pub(crate) const GAME_SELECTION_NES_SQUIRREL_DOMINO: usize = 8; -pub(crate) const GAME_SELECTION_NES_THWAITE: usize = 9; -pub(crate) const GAME_SELECTION_NES_DABG: usize = 10; -pub(crate) const GAME_SELECTION_NES_FALLING: usize = 11; -pub(crate) const GAME_SELECTION_NES_BRICK_BREAKER: usize = 12; -pub(crate) const GAME_SELECTION_NES_ESCAPE_FROM_PONG: usize = 13; -pub(crate) const GAME_SELECTION_NES_RHDE: usize = 14; -pub(crate) const GAME_SELECTION_NES_CONCENTRATION_ROOM: usize = 15; -pub(crate) const GAME_SELECTION_NES_ZAP_RUDER: usize = 16; -pub(crate) const GAME_SELECTION_NES_2048: usize = 17; -pub(crate) const GAME_SELECTION_RUBIKS_CUBE: usize = 18; +pub(crate) const GAME_SELECTION_TRAFFIC: usize = 8; +pub(crate) const GAME_SELECTION_NES_SQUIRREL_DOMINO: usize = 9; +pub(crate) const GAME_SELECTION_NES_THWAITE: usize = 10; +pub(crate) const GAME_SELECTION_NES_DABG: usize = 11; +pub(crate) const GAME_SELECTION_NES_FALLING: usize = 12; +pub(crate) const GAME_SELECTION_NES_BRICK_BREAKER: usize = 13; +pub(crate) const GAME_SELECTION_NES_ESCAPE_FROM_PONG: usize = 14; +pub(crate) const GAME_SELECTION_NES_RHDE: usize = 15; +pub(crate) const GAME_SELECTION_NES_CONCENTRATION_ROOM: usize = 16; +pub(crate) const GAME_SELECTION_NES_ZAP_RUDER: usize = 17; +pub(crate) const GAME_SELECTION_NES_2048: usize = 18; +pub(crate) const GAME_SELECTION_RUBIKS_CUBE: usize = 19; pub(crate) const DEFAULT_GAME_SELECTION: usize = GAME_SELECTION_2048; const BONSAI_V2_ACTIVITY_WINDOW_TICKS: usize = 15 * 60 * 5; @@ -200,11 +201,14 @@ pub struct SessionConfig { pub initial_2048_high_score: Option, pub tetris_service: crate::app::arcade::tetris::svc::LaterisService, pub snake_service: crate::app::arcade::snake::svc::SnakeService, + pub traffic_service: crate::app::arcade::traffic::svc::TrafficService, pub rubiks_cube_service: crate::app::arcade::rubiks_cube::svc::RubiksCubeService, pub initial_tetris_game: Option, pub initial_snake_game: Option, pub initial_tetris_high_score: Option, pub initial_snake_high_score: Option, + pub initial_traffic_track_scores: Vec, + pub initial_traffic_high_score: Option, pub le_word_service: crate::app::arcade::le_word::svc::LeWordService, pub initial_le_word_daily_word: Option, pub initial_le_word_game: Option, @@ -532,6 +536,7 @@ pub struct App { pub(crate) solitaire_state: crate::app::arcade::solitaire::state::State, pub(crate) minesweeper_state: crate::app::arcade::minesweeper::state::State, pub(crate) nes_cabinet_state: crate::app::arcade::nes_cabinet::state::State, + pub(crate) traffic_state: crate::app::arcade::traffic::state::State, pub(crate) active_room_game: Option>, /// Rooms whose current pending turn already emitted a "your turn" /// notification; each room is cleared once that turn passes. @@ -833,6 +838,13 @@ impl App { config.initial_minesweeper_games, ); let nes_cabinet_state = crate::app::arcade::nes_cabinet::state::State::new(); + let mut traffic_state = crate::app::arcade::traffic::state::State::new(); + traffic_state.hydrate( + config.user_id, + config.traffic_service.clone(), + config.initial_traffic_track_scores, + config.initial_traffic_high_score, + ); let rooms_snapshot_rx = config.rooms_service.subscribe_snapshot(); let rooms_snapshot = rooms_snapshot_rx.borrow().clone(); crate::app::dashboard::state::seed_persisted_room_joins_from_rooms( @@ -1143,6 +1155,7 @@ impl App { solitaire_state, minesweeper_state, nes_cabinet_state, + traffic_state, active_room_game: None, rooms_turn_notified_room_ids: HashSet::new(), rooms_last_turn_scan_at: None, diff --git a/late-ssh/src/app/tick.rs b/late-ssh/src/app/tick.rs index e6b7ff63..42246423 100644 --- a/late-ssh/src/app/tick.rs +++ b/late-ssh/src/app/tick.rs @@ -1,6 +1,6 @@ use std::time::Instant; -use super::state::{App, GAME_SELECTION_SNAKE, GAME_SELECTION_TETRIS}; +use super::state::{App, GAME_SELECTION_SNAKE, GAME_SELECTION_TETRIS, GAME_SELECTION_TRAFFIC}; use crate::app::activity::channel::ACTIVITY_HISTORY_MAX_EVENTS; use crate::app::activity::event::ActivityKind; use crate::app::activity::filter::ActivityFilter; @@ -252,6 +252,9 @@ impl App { GAME_SELECTION_SNAKE => { self.snake_state.tick(); } + GAME_SELECTION_TRAFFIC => { + self.traffic_state.tick(); + } selection if crate::app::arcade::input::is_nes_selection(selection) => { self.nes_cabinet_state.tick(); } diff --git a/late-ssh/src/main.rs b/late-ssh/src/main.rs index cabced4a..96d03a10 100644 --- a/late-ssh/src/main.rs +++ b/late-ssh/src/main.rs @@ -200,6 +200,8 @@ async fn main() -> anyhow::Result<()> { .with_activity_feed(activity_tx.clone()); let snake_service = late_ssh::app::arcade::snake::svc::SnakeService::new(db.clone()) .with_activity_feed(activity_tx.clone()); + let traffic_service = late_ssh::app::arcade::traffic::svc::TrafficService::new(db.clone()) + .with_activity_feed(activity_tx.clone()); let rubiks_cube_service = late_ssh::app::arcade::rubiks_cube::svc::RubiksCubeService::new( db.clone(), activity_tx.clone(), @@ -365,6 +367,7 @@ async fn main() -> anyhow::Result<()> { twenty_forty_eight_service, tetris_service, snake_service, + traffic_service, rubiks_cube_service, le_word_service, sudoku_service, diff --git a/late-ssh/src/metrics.rs b/late-ssh/src/metrics.rs index 6b1fd67e..a7c3c443 100644 --- a/late-ssh/src/metrics.rs +++ b/late-ssh/src/metrics.rs @@ -35,6 +35,7 @@ mod inner { ActivityGame::TwentyFortyEight => "2048", ActivityGame::Tron => "tron", ActivityGame::Snake => "snake", + ActivityGame::Traffic => "traffic", } } diff --git a/late-ssh/src/session_bootstrap.rs b/late-ssh/src/session_bootstrap.rs index 566e1ae4..aa06e79b 100644 --- a/late-ssh/src/session_bootstrap.rs +++ b/late-ssh/src/session_bootstrap.rs @@ -33,6 +33,8 @@ pub struct ArcadeSessionPreloads { pub initial_tetris_high_score: Option, pub initial_snake_game: Option, pub initial_snake_high_score: Option, + pub initial_traffic_track_scores: Vec, + pub initial_traffic_high_score: Option, pub initial_le_word_daily_word: Option, pub initial_le_word_game: Option, pub initial_sudoku_games: Vec, @@ -45,6 +47,7 @@ pub async fn load_arcade_session_preloads(state: &State, user_id: Uuid) -> Arcad let twenty_forty_eight_service = state.twenty_forty_eight_service.clone(); let tetris_service = state.tetris_service.clone(); let snake_service = state.snake_service.clone(); + let traffic_service = state.traffic_service.clone(); let le_word_service = state.le_word_service.clone(); let sudoku_service = state.sudoku_service.clone(); let nonogram_service = state.nonogram_service.clone(); @@ -58,6 +61,8 @@ pub async fn load_arcade_session_preloads(state: &State, user_id: Uuid) -> Arcad initial_tetris_high_score, initial_snake_game, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, initial_le_word_daily_word, initial_le_word_game, initial_sudoku_games, @@ -119,6 +124,24 @@ pub async fn load_arcade_session_preloads(state: &State, user_id: Uuid) -> Arcad } } }, + async { + match traffic_service.load_track_scores(user_id).await { + Ok(scores) => scores, + Err(e) => { + tracing::warn!(error = ?e, "failed to load traffic track scores"); + Vec::new() + } + } + }, + async { + match traffic_service.load_high_score(user_id).await { + Ok(score) => score, + Err(e) => { + tracing::warn!(error = ?e, "failed to load traffic high score"); + None + } + } + }, async { match le_word_service.ensure_daily_word().await { Ok(word) => Some(word), @@ -183,6 +206,8 @@ pub async fn load_arcade_session_preloads(state: &State, user_id: Uuid) -> Arcad initial_tetris_high_score, initial_snake_game, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, initial_le_word_daily_word, initial_le_word_game, initial_sudoku_games, @@ -215,6 +240,8 @@ pub async fn build_session_config(state: &State, inputs: SessionBootstrapInputs) initial_tetris_high_score, initial_snake_game, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, initial_le_word_daily_word, initial_le_word_game, initial_sudoku_games, @@ -328,11 +355,14 @@ pub async fn build_session_config(state: &State, inputs: SessionBootstrapInputs) initial_2048_high_score, tetris_service: state.tetris_service.clone(), snake_service: state.snake_service.clone(), + traffic_service: state.traffic_service.clone(), rubiks_cube_service: state.rubiks_cube_service.clone(), initial_tetris_game, initial_snake_game, initial_tetris_high_score, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, le_word_service: state.le_word_service.clone(), initial_le_word_daily_word, initial_le_word_game, diff --git a/late-ssh/src/ssh.rs b/late-ssh/src/ssh.rs index 5d23343a..121077b6 100644 --- a/late-ssh/src/ssh.rs +++ b/late-ssh/src/ssh.rs @@ -715,6 +715,8 @@ impl russh::server::Handler for ClientHandler { initial_tetris_high_score, initial_snake_game, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, initial_le_word_daily_word, initial_le_word_game, initial_sudoku_games, @@ -839,11 +841,14 @@ impl russh::server::Handler for ClientHandler { initial_2048_high_score, tetris_service: self.state.tetris_service.clone(), snake_service: self.state.snake_service.clone(), + traffic_service: self.state.traffic_service.clone(), rubiks_cube_service: self.state.rubiks_cube_service.clone(), initial_tetris_game, initial_snake_game, initial_tetris_high_score, initial_snake_high_score, + initial_traffic_track_scores, + initial_traffic_high_score, le_word_service, initial_le_word_daily_word, initial_le_word_game, diff --git a/late-ssh/src/state.rs b/late-ssh/src/state.rs index 02123d23..66a1df36 100644 --- a/late-ssh/src/state.rs +++ b/late-ssh/src/state.rs @@ -9,6 +9,7 @@ use crate::app::arcade::snake::svc::SnakeService; use crate::app::arcade::solitaire::svc::SolitaireService; use crate::app::arcade::sudoku::svc::SudokuService; use crate::app::arcade::tetris::svc::LaterisService; +use crate::app::arcade::traffic::svc::TrafficService; use crate::app::arcade::twenty_forty_eight::svc::TwentyFortyEightService; use crate::app::artboard::provenance::SharedArtboardProvenance; use crate::app::audio::svc::AudioService; @@ -109,6 +110,7 @@ pub struct State { pub twenty_forty_eight_service: TwentyFortyEightService, pub tetris_service: LaterisService, pub snake_service: SnakeService, + pub traffic_service: TrafficService, pub rubiks_cube_service: RubiksCubeService, pub le_word_service: LeWordService, pub sudoku_service: SudokuService, diff --git a/late-ssh/tests/arcade/main.rs b/late-ssh/tests/arcade/main.rs index b9bc48fe..19f77909 100644 --- a/late-ssh/tests/arcade/main.rs +++ b/late-ssh/tests/arcade/main.rs @@ -6,4 +6,5 @@ mod minesweeper; mod nonogram; mod solitaire; mod sudoku; +mod traffic; mod twenty_forty_eight; diff --git a/late-ssh/tests/arcade/traffic.rs b/late-ssh/tests/arcade/traffic.rs new file mode 100644 index 00000000..ab2a749c --- /dev/null +++ b/late-ssh/tests/arcade/traffic.rs @@ -0,0 +1,74 @@ +use late_core::models::traffic::{HighScore, TrackScore}; + +use super::helpers::new_test_db; +use late_core::test_utils::create_test_user; + +#[tokio::test] +async fn aggregate_high_score_is_sum_of_per_track_bests() { + let test_db = new_test_db().await; + let user = create_test_user(&test_db.db, "traffic-aggregate").await; + let mut client = test_db.db.get().await.expect("db client"); + + // First track: total is just this track. + let total = HighScore::update_track_score_if_higher(&mut client, user.id, "alpha", 700) + .await + .expect("submit alpha"); + assert_eq!(total, 700); + + // Second track: aggregate is the sum of both bests. + let total = HighScore::update_track_score_if_higher(&mut client, user.id, "beta", 250) + .await + .expect("submit beta"); + assert_eq!(total, 950); + + // A lower score on an existing track is ignored (GREATEST), aggregate holds. + let total = HighScore::update_track_score_if_higher(&mut client, user.id, "alpha", 400) + .await + .expect("submit lower alpha"); + assert_eq!(total, 950); + + // A higher score on an existing track raises both the track best and total. + let total = HighScore::update_track_score_if_higher(&mut client, user.id, "alpha", 900) + .await + .expect("submit higher alpha"); + assert_eq!(total, 1150); + + // Per-track bests reflect the GREATEST-kept values. + let mut scores = TrackScore::list_for_user(&client, user.id) + .await + .expect("list track scores"); + scores.sort_by(|a, b| a.track_key.cmp(&b.track_key)); + assert_eq!(scores.len(), 2); + assert_eq!(scores[0].track_key, "alpha"); + assert_eq!(scores[0].score, 900); + assert_eq!(scores[1].track_key, "beta"); + assert_eq!(scores[1].score, 250); + + // Aggregate row mirrors the latest total. + let hs = HighScore::find_by_user_id(&client, user.id) + .await + .expect("load high score") + .expect("existing high score"); + assert_eq!(hs.score, 1150); +} + +#[tokio::test] +async fn record_score_event_writes_a_traffic_row() { + let test_db = new_test_db().await; + let user = create_test_user(&test_db.db, "traffic-score-event").await; + let client = test_db.db.get().await.expect("db client"); + + HighScore::record_score_event(&client, user.id, 1150) + .await + .expect("record score event"); + + let count: i64 = client + .query_one( + "SELECT COUNT(*) FROM game_score_events WHERE user_id = $1 AND game = 'traffic'", + &[&user.id], + ) + .await + .expect("count score events") + .get(0); + assert_eq!(count, 1); +} diff --git a/late-ssh/tests/helpers/mod.rs b/late-ssh/tests/helpers/mod.rs index d838c74f..d5df4abd 100644 --- a/late-ssh/tests/helpers/mod.rs +++ b/late-ssh/tests/helpers/mod.rs @@ -18,6 +18,7 @@ use late_ssh::app::arcade::snake::svc::SnakeService; use late_ssh::app::arcade::solitaire::svc::SolitaireService; use late_ssh::app::arcade::sudoku::svc::SudokuService; use late_ssh::app::arcade::tetris::svc::LaterisService; +use late_ssh::app::arcade::traffic::svc::TrafficService; use late_ssh::app::arcade::twenty_forty_eight::svc::TwentyFortyEightService; use late_ssh::app::artboard::provenance::ArtboardProvenance; use late_ssh::app::bonsai::svc::BonsaiService; @@ -248,6 +249,7 @@ pub fn test_app_state(db: Db, config: Config) -> State { let twenty_forty_eight_service = TwentyFortyEightService::new(db.clone()); let tetris_service = LaterisService::new(db.clone()); let snake_service = SnakeService::new(db.clone()); + let traffic_service = TrafficService::new(db.clone()); let le_word_service = LeWordService::new(db.clone(), activity_tx.clone()); let rubiks_cube_service = RubiksCubeService::new(db.clone(), activity_tx.clone()); let chip_service = ChipService::new(db.clone()); @@ -306,6 +308,7 @@ pub fn test_app_state(db: Db, config: Config) -> State { twenty_forty_eight_service, tetris_service, snake_service, + traffic_service, le_word_service, rubiks_cube_service, sudoku_service, @@ -439,10 +442,13 @@ fn make_app_with_chat_service_and_permissions( initial_2048_high_score: None, tetris_service: LaterisService::new(db.clone()), snake_service: SnakeService::new(db.clone()), + traffic_service: TrafficService::new(db.clone()), initial_tetris_game: None, initial_snake_game: None, initial_tetris_high_score: None, initial_snake_high_score: None, + initial_traffic_track_scores: Vec::new(), + initial_traffic_high_score: None, le_word_service: LeWordService::new(db.clone(), broadcast::channel::(64).0), rubiks_cube_service: RubiksCubeService::new(db.clone(), activity_tx.clone()), initial_le_word_daily_word: None, @@ -594,10 +600,13 @@ pub fn make_app_with_paired_client( initial_2048_high_score: None, tetris_service: LaterisService::new(db.clone()), snake_service: SnakeService::new(db.clone()), + traffic_service: TrafficService::new(db.clone()), initial_tetris_game: None, initial_snake_game: None, initial_tetris_high_score: None, initial_snake_high_score: None, + initial_traffic_track_scores: Vec::new(), + initial_traffic_high_score: None, le_word_service: LeWordService::new(db.clone(), broadcast::channel::(64).0), rubiks_cube_service: RubiksCubeService::new(db.clone(), activity_tx.clone()), initial_le_word_daily_word: None,