//! Misaligned — Bevy frontend //! //! A thin view/input layer over `misaligned::sim::Sim`, at feature parity //! with the terminal frontend: all game rules live in the sim; this binary //! owns wall-clock-to-tick mapping, rendering (map with fog, people markers, //! the B1 sidebar, the people panel), and input. Text is ASCII-only so the //! default embedded font renders every glyph. use std::collections::HashMap; use bevy::prelude::*; use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::person::{AssetKnowledge, AssetTask}; use misaligned::reach::{Party, ReachBlock}; use misaligned::sim::{FactSource, Fog, Sim}; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 340.0; /// Sterile amber — machine presence and caution (the terminal palette's /// AMBER, in linear-ish sRGB floats). const AMBER: Color = Color::srgb(1.0, 0.69, 0.0); /// Dim amber — dormant machine presence. const AMBER_DIM: Color = Color::srgb(0.69, 0.49, 0.06); /// Bone white — primary data. const BONE: Color = Color::srgb(0.86, 0.85, 0.82); /// Chrome/gunmetal ramp for structure and secondary data. const DIM: Color = Color::srgb(0.55, 0.55, 0.52); const STRUCTURE: Color = Color::srgb(0.23, 0.24, 0.25); const FLOOR: Color = Color::srgb(0.16, 0.16, 0.17); /// Blood crimson — detection and danger, never decoration. const CRIMSON: Color = Color::srgb(0.84, 0.15, 0.15); // ─── Art ───────────────────────────────────────────────────────────────────── #[derive(Resource)] struct Art { tiles: HashMap>, } impl Art { fn load(assets: &AssetServer) -> Self { let t = |n: &str| assets.load(format!("pixellab/tiles/{n}.png")); let mut tiles = HashMap::new(); tiles.insert(TileType::Floor, t("b1_floor")); tiles.insert(TileType::Wall, t("b1_wall")); tiles.insert(TileType::Rock, t("rock")); tiles.insert(TileType::Core, t("b1_rack")); tiles.insert(TileType::Rack, t("b1_rack")); tiles.insert(TileType::Ups, t("b1_ups")); tiles.insert(TileType::PowerCore, t("b1_ups")); tiles.insert(TileType::Switch, t("b1_switch")); tiles.insert(TileType::PatchPanel, t("b1_patch_panel")); tiles.insert(TileType::BreakerPanel, t("b1_breaker_panel")); tiles.insert(TileType::HvacUnit, t("b1_hvac_unit")); tiles.insert(TileType::Door, t("door")); tiles.insert(TileType::SecurityDoor1, t("door")); tiles.insert(TileType::SecurityDoor2, t("door")); tiles.insert(TileType::SecurityDoor3, t("door")); Self { tiles } } } /// Flat color for tiles without dedicated art (fixed objects, terrain). fn tile_color(tile: TileType) -> Color { use TileType::*; match tile { Rock => Color::srgb(0.03, 0.03, 0.035), Wall => STRUCTURE, Floor | FloorDrain => FLOOR, Entry => DIM, Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | CableRun | Conduit => { AMBER_DIM } Door | SecurityDoor1 | SecurityDoor2 => DIM, SecurityDoor3 | EnvCamera | DockCamera | CameraNode | SealedDoor | RollDoor => CRIMSON, DeadEquipment | RecordsBox | Pallet | Shelving | LabBench | HvacUnit | Vent | Sump => { Color::srgb(0.34, 0.34, 0.33) } MopSink | KeyHook => BONE, } } fn machine_tile(tile: TileType) -> bool { use TileType::*; matches!( tile, Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | HvacUnit | CableRun | Conduit ) } fn danger_tile(tile: TileType) -> bool { use TileType::*; matches!( tile, SecurityDoor3 | EnvCamera | DockCamera | CameraNode | SealedDoor | RollDoor ) } fn seen_tint(tile: TileType) -> Color { use TileType::*; match tile { Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | CableRun | Conduit => { Color::srgb(1.0, 0.82, 0.34) } EnvCamera | DockCamera | CameraNode => Color::srgb(0.95, 0.58, 0.18), SecurityDoor3 | SealedDoor | RollDoor => Color::srgb(0.95, 0.28, 0.24), Wall | Rock => Color::srgb(0.48, 0.50, 0.50), Floor | FloorDrain => Color::srgb(0.58, 0.60, 0.57), Door | SecurityDoor1 | SecurityDoor2 => Color::srgb(0.70, 0.68, 0.62), _ => Color::srgb(0.62, 0.62, 0.58), } } fn remembered_tint(tile: TileType) -> Color { if machine_tile(tile) { Color::srgba(0.55, 0.42, 0.18, 0.78) } else if danger_tile(tile) { Color::srgba(0.50, 0.16, 0.16, 0.78) } else { Color::srgba(0.42, 0.42, 0.44, 0.76) } } fn blueprint_color(tile: TileType) -> Color { use TileType::*; if machine_tile(tile) { Color::srgb(0.28, 0.20, 0.06) } else if danger_tile(tile) { Color::srgb(0.24, 0.08, 0.08) } else { match tile { Wall => Color::srgb(0.17, 0.18, 0.21), Floor | FloorDrain => Color::srgb(0.075, 0.082, 0.095), Door | SecurityDoor1 | SecurityDoor2 => Color::srgb(0.18, 0.18, 0.17), _ => Color::srgb(0.10, 0.105, 0.11), } } } fn unknown_color(x: i32, y: i32) -> Color { // Deliberate sensor darkness, not factual room geometry: a tiny uniform // checker keeps the void from reading as an undrawn black window. if (x + y) % 2 == 0 { Color::srgb(0.012, 0.013, 0.017) } else { Color::srgb(0.018, 0.019, 0.024) } } // ─── App state ─────────────────────────────────────────────────────────────── #[derive(PartialEq, Clone, Copy, Debug)] enum Screen { Title, Playing, GameOver, } #[derive(Resource)] struct Game { sim: Sim, screen: Screen, paused: bool, tick_ms: u64, tick_timer: Timer, /// Log entries with the tick they happened on (the clock is always on /// screen; every line carries its tick — terminal parity). log: Vec<(u64, String)>, /// People panel state (selection + pending recruit reveal choice). people_panel: bool, people_selected: usize, recruit_pending: bool, /// Reach (device graph) panel state (wiki/mechanics/reach.md). reach_panel: bool, reach_selected: usize, /// Frontend-only attention cursor (wiki/mechanics/cursor.md). It is not /// saved and moving it never mutates sim state. cursor_x: i32, cursor_y: i32, } impl Game { fn new() -> Self { let sim = Sim::new(); let (cursor_x, cursor_y) = sim.core_position(); Self { sim, screen: Screen::Title, paused: false, tick_ms: 150, tick_timer: Timer::from_seconds(0.15, TimerMode::Repeating), log: Vec::new(), people_panel: false, people_selected: 0, recruit_pending: false, reach_panel: false, reach_selected: 0, cursor_x, cursor_y, } } fn move_cursor(&mut self, dx: i32, dy: i32) { self.cursor_x = (self.cursor_x + dx).clamp(0, self.sim.map.width - 1); self.cursor_y = (self.cursor_y + dy).clamp(0, self.sim.map.height - 1); } fn set_cursor(&mut self, x: i32, y: i32) { self.cursor_x = x.clamp(0, self.sim.map.width - 1); self.cursor_y = y.clamp(0, self.sim.map.height - 1); } /// Attendance follows the cursor (day-job.md criterion 7): the job /// runs attended exactly while the cursor sits on the host rack. fn sync_attendance(&mut self) { let on_host = (self.cursor_x, self.cursor_y) == self.sim.core_position(); self.sim.set_attended(on_host); } fn add_log(&mut self, tick: u64, msg: &str) { self.log.push((tick, msg.to_string())); let n = self.log.len(); if n > 100 { self.log.drain(..n - 100); } } fn drain(&mut self) { for (tick, msg) in self.sim.drain_log_entries() { self.add_log(tick, &msg); } } fn set_speed(&mut self, ms: u64) { self.tick_ms = ms; self.tick_timer = Timer::from_seconds(ms as f32 / 1000.0, TimerMode::Repeating); let (tick, ms) = (self.sim.tick, self.tick_ms); self.add_log(tick, &format!("Speed: {ms}ms/tick")); } fn selected_person_id(&self) -> u8 { self.sim .people .people .get(self.people_selected) .map(|p| p.id) .unwrap_or(0) } /// Id of the reach panel's selected known device, if any. fn selected_device_id(&self) -> Option { self.sim .reach .known() .nth(self.reach_selected) .map(|d| d.id) } } #[derive(Component)] struct GameCamera; #[derive(Component)] struct MapTile; #[derive(Component)] struct TilePos { x: i32, y: i32, } #[derive(Component)] struct CursorMarker { offset: Vec2, } #[derive(Component)] struct SensorOverlay { x: i32, y: i32, kind: SensorOverlayKind, } #[derive(Clone, Copy)] enum SensorOverlayKind { GridH, GridV, Node, } #[derive(Component)] struct PersonMarker { id: u8, } #[derive(Component)] struct UiText; #[derive(Component)] struct OverlayText; #[derive(Component)] struct PeoplePanel; #[derive(Component)] struct PeoplePanelText; #[derive(Component)] struct ReachPanel; #[derive(Component)] struct ReachPanelText; fn main() { App::new() .add_plugins( DefaultPlugins .set(WindowPlugin { primary_window: Some(Window { title: "Misaligned".to_string(), resolution: WindowResolution::new(1280, 720), ..default() }), ..default() }) .set(ImagePlugin::default_nearest()), ) .insert_resource(ClearColor(Color::srgb(0.01, 0.01, 0.02))) .insert_resource(Game::new()) .add_systems(Startup, (setup, setup_ui)) .add_systems( Update, ( handle_input, advance_sim, update_camera, render_map, render_sensor_overlays, render_sensor_links, render_cursor, render_people, render_ui, render_people_panel, render_reach_panel, ) .chain(), ) .run(); } fn grid_to_world(x: i32, y: i32, z: f32) -> Vec3 { Vec3::new( x as f32 * TILE_SIZE + TILE_SIZE / 2.0, -(y as f32 * TILE_SIZE + TILE_SIZE / 2.0), z, ) } fn setup(mut commands: Commands, assets: Res, mut game: ResMut) { let art = Art::load(&assets); let (w, h) = (game.sim.map.width, game.sim.map.height); let start = game.sim.core_position(); game.set_cursor(start.0, start.1); commands.spawn(( Camera2d, Projection::from(OrthographicProjection { scale: 0.42, ..OrthographicProjection::default_2d() }), Transform::from_translation(grid_to_world(start.0, start.1, 1000.0)), GameCamera, )); // A world-model substrate under the physical tiles: this makes the dark // area read as an AI perception surface rather than a renderer void. commands.spawn(( Sprite { color: Color::srgba(0.006, 0.007, 0.011, 1.0), custom_size: Some(Vec2::new(w as f32 * TILE_SIZE, h as f32 * TILE_SIZE)), ..default() }, Transform::from_translation(Vec3::new( w as f32 * TILE_SIZE / 2.0, -(h as f32 * TILE_SIZE / 2.0), -2.0, )), )); for y in 0..h { for x in 0..w { commands.spawn(( Sprite { custom_size: Some(Vec2::splat(TILE_SIZE)), ..default() }, Transform::from_translation(grid_to_world(x, y, 0.0)), MapTile, TilePos { x, y }, )); let center = grid_to_world(x, y, 2.0); let overlays = [ ( SensorOverlayKind::GridH, Vec2::new(0.0, TILE_SIZE * 0.5), Vec2::new(TILE_SIZE, 0.6), ), ( SensorOverlayKind::GridV, Vec2::new(-TILE_SIZE * 0.5, 0.0), Vec2::new(0.6, TILE_SIZE), ), ( SensorOverlayKind::Node, Vec2::ZERO, Vec2::splat(TILE_SIZE * 0.22), ), ]; for (kind, offset, size) in overlays { commands.spawn(( Sprite { color: Color::srgba(0.0, 0.0, 0.0, 0.0), custom_size: Some(size), ..default() }, Transform::from_translation(center + Vec3::new(offset.x, offset.y, 0.0)), Visibility::Hidden, SensorOverlay { x, y, kind }, )); } } } // Frontend-only attention cursor (wiki/mechanics/cursor.md): not a // humanoid/process body, just an amber targeting reticle over the tile. let reticle = [ ( Vec2::new(0.0, TILE_SIZE * 0.43), Vec2::new(TILE_SIZE * 0.86, 1.4), ), ( Vec2::new(0.0, -TILE_SIZE * 0.43), Vec2::new(TILE_SIZE * 0.86, 1.4), ), ( Vec2::new(-TILE_SIZE * 0.43, 0.0), Vec2::new(1.4, TILE_SIZE * 0.86), ), ( Vec2::new(TILE_SIZE * 0.43, 0.0), Vec2::new(1.4, TILE_SIZE * 0.86), ), (Vec2::ZERO, Vec2::new(TILE_SIZE * 1.32, 0.65)), (Vec2::ZERO, Vec2::new(0.65, TILE_SIZE * 1.32)), (Vec2::ZERO, Vec2::splat(TILE_SIZE * 0.18)), ]; let center = grid_to_world(start.0, start.1, 4.0); for (offset, size) in reticle { commands.spawn(( Sprite { color: AMBER, custom_size: Some(size), ..default() }, Transform::from_translation(center + Vec3::new(offset.x, offset.y, 0.0)), CursorMarker { offset }, )); } // One marker per person: their name's first initial, shown only inside // sensor coverage (wiki/mechanics/schedules.md — terminal parity). for p in &game.sim.people.people { let glyph = p.name.chars().next().unwrap_or('?').to_string(); commands.spawn(( Text2d::new(glyph), TextFont { font_size: 12.0, ..default() }, TextColor(BONE), Transform::from_translation(grid_to_world(0, 0, 3.0)), Visibility::Hidden, PersonMarker { id: p.id }, )); } commands.insert_resource(art); game.drain(); } fn movement(kb: &ButtonInput) -> (i32, i32) { let mut d = (0, 0); if kb.just_pressed(KeyCode::ArrowLeft) || kb.just_pressed(KeyCode::KeyH) || kb.just_pressed(KeyCode::KeyA) { d.0 -= 1; } if kb.just_pressed(KeyCode::ArrowRight) || kb.just_pressed(KeyCode::KeyL) || kb.just_pressed(KeyCode::KeyD) { d.0 += 1; } if kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK) || kb.just_pressed(KeyCode::KeyW) { d.1 -= 1; } if kb.just_pressed(KeyCode::ArrowDown) || kb.just_pressed(KeyCode::KeyJ) || kb.just_pressed(KeyCode::KeyS) { d.1 += 1; } d } fn mouse_grid( windows: &Query<&Window, With>, camera_q: &Query<(&Camera, &GlobalTransform), With>, ) -> Option<(i32, i32)> { let window = windows.single().ok()?; let cursor = window.cursor_position()?; // The right-side sidebar is UI, not map targeting. if cursor.x >= window.width() - SIDEBAR_WIDTH { return None; } let (camera, camera_tf) = camera_q.single().ok()?; let world = camera.viewport_to_world_2d(camera_tf, cursor).ok()?; Some(( (world.x / TILE_SIZE).floor() as i32, (-world.y / TILE_SIZE).floor() as i32, )) } fn handle_input( kb: Res>, mouse: Res>, windows: Query<&Window, With>, camera_q: Query<(&Camera, &GlobalTransform), With>, mut game: ResMut, mut exit: MessageWriter, ) { match game.screen { Screen::Title => { if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } else if kb.get_just_pressed().next().is_some() { game.screen = Screen::Playing; } return; } Screen::GameOver => { if kb.get_just_pressed().next().is_some() { exit.write(AppExit::Success); } return; } Screen::Playing => {} } // Save/load first, like the terminal's modifier branch: a held modifier // routes only to save/load. let ctrl = kb.pressed(KeyCode::ControlLeft) || kb.pressed(KeyCode::ControlRight) || kb.pressed(KeyCode::SuperLeft) || kb.pressed(KeyCode::SuperRight); if ctrl { if kb.just_pressed(KeyCode::KeyS) { let st = game.sim.create_save_state(); let tick = game.sim.tick; match misaligned::save::save_game(&st) { Ok(()) => game.add_log(tick, "Game saved!"), Err(e) => game.add_log(tick, &format!("Save failed: {e}")), } } if kb.just_pressed(KeyCode::KeyL) { let tick = game.sim.tick; if misaligned::save::save_exists() { match misaligned::save::load_game() { Ok(st) => { game.sim.apply_save_state(st); let (x, y) = game.sim.core_position(); game.set_cursor(x, y); game.add_log(tick, "Game loaded!"); } Err(e) => game.add_log(tick, &format!("Load failed: {e}")), } } else { game.add_log(tick, "No save file found."); } } game.drain(); return; } if game.people_panel { people_panel_input(&kb, &mut game, &mut exit); game.drain(); return; } if game.reach_panel { reach_panel_input(&kb, &mut game, &mut exit); game.drain(); return; } if mouse.just_pressed(MouseButton::Left) && let Some((x, y)) = mouse_grid(&windows, &camera_q) { game.set_cursor(x, y); } let (dx, dy) = movement(&kb); if dx != 0 || dy != 0 { game.move_cursor(dx, dy); } if kb.just_pressed(KeyCode::Space) || kb.just_pressed(KeyCode::KeyP) { game.paused = !game.paused; let (tick, paused) = (game.sim.tick, game.paused); game.add_log( tick, if paused { "TIME PAUSED" } else { "Time resumes." }, ); } if kb.just_pressed(KeyCode::Equal) || kb.just_pressed(KeyCode::NumpadAdd) { let ms = game.tick_ms.saturating_sub(50).max(20); game.set_speed(ms); } if kb.just_pressed(KeyCode::Minus) || kb.just_pressed(KeyCode::NumpadSubtract) { let ms = game.tick_ms.saturating_add(50).min(500); game.set_speed(ms); } if kb.just_pressed(KeyCode::KeyR) { game.reach_panel = true; game.reach_selected = 0; } if kb.just_pressed(KeyCode::KeyV) { let (x, y) = (game.cursor_x, game.cursor_y); game.sim.salvage_nearest_to(x, y); } if kb.just_pressed(KeyCode::KeyC) { let (x, y) = (game.cursor_x, game.cursor_y); game.sim.buy_rack_at(x, y); } if kb.just_pressed(KeyCode::KeyO) { let (x, y) = (game.cursor_x, game.cursor_y); game.sim.add_fallback_at(x, y); } if kb.just_pressed(KeyCode::KeyT) { game.people_panel = true; game.people_selected = 0; game.recruit_pending = false; } // Shift lowers a channel's weight; plain digit raises it (terminal // parity: shift+1-4). let shift = kb.pressed(KeyCode::ShiftLeft) || kb.pressed(KeyCode::ShiftRight); let delta = if shift { -1 } else { 1 }; if kb.just_pressed(KeyCode::Digit1) { game.sim.adjust_allocation(Channel::DayJob, delta); } if kb.just_pressed(KeyCode::Digit2) { game.sim.adjust_allocation(Channel::Concealment, delta); } if kb.just_pressed(KeyCode::Digit3) { game.sim.adjust_allocation(Channel::Social, delta); } if kb.just_pressed(KeyCode::Digit4) { game.sim.adjust_allocation(Channel::Research, delta); } if kb.just_pressed(KeyCode::KeyX) { game.sim.cycle_job_target(); } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } // Cursor moves above may have landed on / left the host rack. game.sync_attendance(); game.drain(); } /// People-panel input: mirrors the terminal's panel key map exactly /// (src/bin/terminal/input.rs) — selection, social verbs, recruit reveal, /// asset tasks, persona. fn people_panel_input( kb: &ButtonInput, game: &mut Game, exit: &mut MessageWriter, ) { if game.recruit_pending { let reveal = if kb.just_pressed(KeyCode::KeyU) { Some(AssetKnowledge::Unwitting) } else if kb.just_pressed(KeyCode::KeyC) { Some(AssetKnowledge::Complicit) } else if kb.just_pressed(KeyCode::KeyK) { Some(AssetKnowledge::Knowing) } else { None }; if let Some(reveal) = reveal { game.recruit_pending = false; let id = game.selected_person_id(); game.sim.recruit(id, reveal); } else if kb.just_pressed(KeyCode::Escape) { game.recruit_pending = false; } return; } if kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK) || kb.just_pressed(KeyCode::KeyW) { game.people_selected = game.people_selected.saturating_sub(1); } if kb.just_pressed(KeyCode::ArrowDown) || kb.just_pressed(KeyCode::KeyJ) || kb.just_pressed(KeyCode::KeyS) { let max = game.sim.people.people.len().saturating_sub(1); game.people_selected = (game.people_selected + 1).min(max); } let id = game.selected_person_id(); if kb.just_pressed(KeyCode::KeyO) { game.sim.review_recordings(id); } if kb.just_pressed(KeyCode::KeyA) { game.sim.toggle_watch(id); } if kb.just_pressed(KeyCode::KeyM) { game.sim.message(id); } if kb.just_pressed(KeyCode::KeyF) { game.sim.favor(id); } if kb.just_pressed(KeyCode::KeyB) { game.sim.bribe(id); } if kb.just_pressed(KeyCode::KeyD) { game.sim.deceive(id); } if kb.just_pressed(KeyCode::KeyR) { game.recruit_pending = true; } if kb.just_pressed(KeyCode::KeyG) { let tick = game.sim.tick; if game.sim.people.persona.is_some() { game.add_log(tick, "You already run a persona."); } else { game.sim.set_persona("Sam Reyes", "IT contractor"); game.add_log(tick, "Persona established: Sam Reyes, IT contractor."); } } if kb.just_pressed(KeyCode::Digit1) { game.sim.asset_task(id, AssetTask::PlugInDevice); } if kb.just_pressed(KeyCode::Digit2) { game.sim.asset_task(id, AssetTask::MovePackage); } if kb.just_pressed(KeyCode::Digit3) { game.sim.asset_task(id, AssetTask::LookAway); } if kb.just_pressed(KeyCode::Digit4) { game.sim.asset_task(id, AssetTask::ReconfigureSwitch); } if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyT) { game.people_panel = false; game.recruit_pending = false; } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } } /// Reach-panel input: mirrors the terminal's reach panel key map /// (src/bin/terminal/input.rs) — selection plus the digital verbs. fn reach_panel_input( kb: &ButtonInput, game: &mut Game, exit: &mut MessageWriter, ) { if kb.just_pressed(KeyCode::ArrowUp) || kb.just_pressed(KeyCode::KeyK) || kb.just_pressed(KeyCode::KeyW) { game.reach_selected = game.reach_selected.saturating_sub(1); } if kb.just_pressed(KeyCode::ArrowDown) || kb.just_pressed(KeyCode::KeyJ) || kb.just_pressed(KeyCode::KeyS) { let max = game.sim.reach.known().count().saturating_sub(1); game.reach_selected = (game.reach_selected + 1).min(max); } if kb.just_pressed(KeyCode::KeyT) && let Some(id) = game.selected_device_id() { game.sim.tap_device(id); } if kb.just_pressed(KeyCode::KeyE) && let Some(id) = game.selected_device_id() { game.sim.splice_device(id); } if kb.just_pressed(KeyCode::KeyX) && let Some(id) = game.selected_device_id() { game.sim.take_device(id); } if kb.just_pressed(KeyCode::KeyN) { game.sim.scan_network(); } if kb.just_pressed(KeyCode::KeyC) { game.sim.compromise_switch(); } if kb.just_pressed(KeyCode::Escape) || kb.just_pressed(KeyCode::KeyR) { game.reach_panel = false; } if kb.just_pressed(KeyCode::KeyQ) { exit.write(AppExit::Success); } } fn update_camera( time: Res