//! 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), and input. Text is ASCII-only so the //! default embedded font renders every glyph. use std::collections::HashMap; use bevy::asset::RenderAssetUsages; use bevy::camera::visibility::RenderLayers; use bevy::ecs::hierarchy::ChildSpawnerCommands; use bevy::input::mouse::{MouseScrollUnit, MouseWheel}; use bevy::prelude::*; use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::text::LineHeight; use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::actions::{Anchor, MenuRow, menu_rows}; use misaligned::detection::Band; use misaligned::machine::Channel; use misaligned::reach::Party; use misaligned::sim::{FactSource, Fog, LogEvent, Sim}; use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 420.0; const SIDEBAR_SCROLL_LINE: f32 = 18.0; const SIDEBAR_SCROLL_PAGE: f32 = 180.0; const COMPUTE_CHANNELS: usize = 5; /// Context-menu card width. Long `verb | cost | [band]` lines need room; /// the embedded font cannot wrap mid-glyph cleanly, so prefer width over /// truncation (wiki/interface/context-menu.md). const MENU_WIDTH: f32 = 460.0; const DETECTION_ROWS: usize = 6; const DETECTION_CELLS: usize = 4; // Material render (HD-2D, default physical view): one 3D world unit per tile. const WALL_HEIGHT: f32 = 1.5; const ROCK_HEIGHT: f32 = 2.0; /// Cutaway height for south-facing structural mass (wall/rock tiles whose /// known neighbor toward the camera-side interior is open): low enough to /// read over, high enough to still read as wall footprint /// (wiki/interface/material-render.md debt 1, decided by screenshot /// comparison against a steeper pitch). const CUTAWAY_HEIGHT: f32 = 0.34; /// Door slabs cut lower than full height but higher than walls, so a doorway /// in a cutaway wall line still reads as a door, not a parapet gap. const DOOR_CUT_HEIGHT: f32 = 0.62; /// Camera pitch below the horizon for the material render (35-45 degrees is /// the Octopath band; 40 keeps tile footprints readable). const CAMERA_TILT_DEG: f32 = 40.0; // ─── Palette ───────────────────────────────────────────────────────────────── /// The clinical palette — the single named table every world material in /// BOTH render modes draws from (DESIGN.md "Visual identity: clinical gore", /// flat-materials refinement 2026-07-08; wiki/interface/flat-materials.md). /// The whole look retunes from here; no world surface may carry a color that /// is not a named entry or a stated treatment (scale/alpha) of one. /// /// Color roles are law, one meaning each: /// - the clinical ramp (porcelain / bone / chrome / gunmetal / near-black) /// carries structure and data; /// - AMBER is machine presence and selection, nothing else; /// - CRIMSON is gore and security information, nothing else; /// - SIGNAL (cold server-light) is power, screens, and live feeds. mod palette { use bevy::prelude::Color; // The clinical ramp. /// Bone white — primary data, person silhouettes. pub const BONE: Color = Color::srgb(0.86, 0.85, 0.82); /// Porcelain — lit lab surfaces: seen floor panels. pub const PORCELAIN: Color = Color::srgb(0.72, 0.73, 0.71); /// Chrome — secondary data, hints, inert fixtures. pub const DIM: Color = Color::srgb(0.55, 0.55, 0.52); /// Gunmetal — structural mass: walls. pub const GUNMETAL: Color = Color::srgb(0.33, 0.35, 0.38); /// Dark gunmetal — machine chassis, furniture, dead equipment. pub const GUNMETAL_DARK: Color = Color::srgb(0.19, 0.20, 0.215); /// Near-black — rock mass and sensor darkness. pub const NEAR_BLACK: Color = Color::srgb(0.045, 0.047, 0.053); // The signals. /// Sterile amber — machine presence and selection ONLY. pub const AMBER: Color = Color::srgb(1.0, 0.69, 0.0); /// Dim amber — dormant/known machine presence. pub const AMBER_DIM: Color = Color::srgb(0.69, 0.49, 0.06); /// Blood crimson — gore and security information ONLY. pub const CRIMSON: Color = Color::srgb(0.84, 0.15, 0.15); /// Cold server-light — power, screens, live feeds ONLY. pub const SIGNAL: Color = Color::srgb(0.36, 0.82, 0.78); // World material families that are not a bare ramp entry (flat base // colors; the render modes differ only in treatment, never language). /// Door slabs (badge tiers 0-2): worn porcelain-bone plate. pub const DOOR_SLAB: Color = Color::srgb(0.60, 0.575, 0.50); /// Security hardware (cameras, sealed/roll/tier-3 doors): crimson /// information on a dark housing. pub const SECURITY: Color = Color::srgb(0.45, 0.10, 0.10); /// Inert furniture (shelving, benches, boxes, pallets). pub const FURNITURE: Color = Color::srgb(0.36, 0.36, 0.34); /// Service floor under cable runs / conduit — machine presence /// underfoot, amber-warmed. pub const FLOOR_SERVICE: Color = Color::srgb(0.62, 0.52, 0.32); /// The one sanctioned treatment: scale a named entry toward dark. Fog /// and schematic looks are scaled palette entries, not new colors. pub fn scaled(c: Color, k: f32) -> Color { let s = c.to_srgba(); Color::srgb(s.red * k, s.green * k, s.blue * k) } } use palette::{ AMBER, AMBER_DIM, BONE, CRIMSON, DIM, DOOR_SLAB, FLOOR_SERVICE, FURNITURE, GUNMETAL, GUNMETAL_DARK, NEAR_BLACK, PORCELAIN, SECURITY, SIGNAL, scaled, }; /// Flat base color per material family (flat-materials.md: material /// families, not per-tile art). This is the Seen-under-light albedo; fog /// states and the flat sensorium apply treatments over the same names. fn family_color(tile: TileType) -> Color { use TileType::*; match tile { Rock => NEAR_BLACK, Wall => GUNMETAL, Floor | FloorDrain => PORCELAIN, Entry => DIM, CableRun | Conduit => FLOOR_SERVICE, Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | HvacUnit => { GUNMETAL_DARK } Door | SecurityDoor1 | SecurityDoor2 => DOOR_SLAB, SecurityDoor3 | SealedDoor | RollDoor | EnvCamera | DockCamera | CameraNode => SECURITY, DeadEquipment => scaled(GUNMETAL_DARK, 0.65), RecordsBox | Pallet | Shelving | LabBench | Vent | Sump => FURNITURE, 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 ) } /// Tiles extruded to boxes in the material (HD-2D) render: structural mass /// and door slabs. Everything else stays a floor plane and/or a billboard. fn blocky_tile(tile: TileType) -> bool { use TileType::*; matches!( tile, Wall | Rock | Door | SecurityDoor1 | SecurityDoor2 | SecurityDoor3 | SealedDoor | RollDoor ) } /// Tiles drawn as upright flat billboarded quads in the material render: /// machines, cameras, and furniture. fn prop_tile(tile: TileType) -> bool { use TileType::*; matches!( tile, Core | Rack | PowerCore | Ups | Switch | PatchPanel | BreakerPanel | HvacUnit | EnvCamera | DockCamera | CameraNode | DeadEquipment | RecordsBox | Pallet | Shelving | LabBench | Vent | Sump | MopSink | KeyHook ) } /// The flat sensorium (one F3 from the default material render) is /// intentionally schematic: the same palette families as the material /// render under a darker unlit treatment, so devices, fog, and cursor /// semantics read first. Machine presence keeps its amber language here /// (in 3D the same fact is carried by emissive + the amber lights). fn flat_seen_color(tile: TileType) -> Color { use TileType::*; if machine_tile(tile) { return scaled(AMBER_DIM, 0.37); } match tile { EnvCamera | DockCamera | CameraNode => scaled(SECURITY, 0.75), MopSink | KeyHook => scaled(BONE, 0.65), Rock => scaled(NEAR_BLACK, 0.55), DeadEquipment | RecordsBox | Pallet | Shelving | LabBench | Vent | Sump => { scaled(FURNITURE, 0.83) } _ => scaled(family_color(tile), 0.62), } } fn flat_remembered_color(tile: TileType) -> Color { let c = flat_seen_color(tile).to_srgba(); Color::srgba(c.red * 0.58, c.green * 0.58, c.blue * 0.60, 0.86) } /// Remembered (model-state) treatment for 3D blocks and props: the family /// language survives, desaturated toward the unlit snapshot. fn remembered_tint(tile: TileType) -> Color { if machine_tile(tile) { scaled(AMBER_DIM, 0.80) } else if danger_tile(tile) { scaled(CRIMSON, 0.60) } else { scaled(DIM, 0.78) } } fn blueprint_color(tile: TileType) -> Color { use TileType::*; if machine_tile(tile) { scaled(AMBER_DIM, 0.41) } else if danger_tile(tile) { scaled(CRIMSON, 0.29) } else { match tile { Wall => scaled(GUNMETAL, 0.53), Floor | FloorDrain => scaled(PORCELAIN, 0.115), Door | SecurityDoor1 | SecurityDoor2 => scaled(DOOR_SLAB, 0.30), _ => scaled(DIM, 0.19), } } } 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 { scaled(NEAR_BLACK, 0.28) } else { scaled(NEAR_BLACK, 0.42) } } // ─── Render mode (HD-2D material default) ──────────────────────────────────── /// Frontend-only render mode for the physical/real canvas (F3). This is pure /// presentation over the same sim state, same anchors, and same fog /// (wiki/interface/views.md): flipping it changes nothing in the sim and is /// never saved. /// /// `material == true` (default; wiki/interface/material-render.md criterion /// 7) is the material render: a tilted Camera3d over the same tile grid, /// floor planes, extruded wall boxes, billboarded props/people, and a few /// point lights. `material == false` is the flat sensorium render, one F3 /// away. #[derive(Resource)] struct RenderMode { material: bool, /// Forces a full 3D restyle on the next frame (set on toggle). dirty: bool, /// User zoom multiplier for the material camera ([ and ]). zoom: f32, } impl Default for RenderMode { fn default() -> Self { Self { material: true, dirty: true, zoom: 1.0, } } } /// Dev screenshot harness (env `MISALIGNED_SHOT=flat|wide|close|dark| /// zoomin|zoomout`, path via `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for /// assets, runs the fog audit, saves one screenshot, exits. Not a player /// surface; exists so render passes can be reviewed from deterministic PNGs. #[derive(Resource)] struct ShotHarness { path: String, frames: u32, taken: bool, } /// Which piece of a tile's 3D representation an entity is. #[derive(Clone, Copy, PartialEq, Debug)] enum TilePart { Floor, Block, /// Dedicated cap over a block's top face: without a separate treatment, /// the top inherits the side material and reads as a flat slab /// (material-render.md debt 2). Top, Prop, } #[derive(Component)] struct Tile3d { x: i32, y: i32, part: TilePart, } #[derive(Component)] struct Person3d { id: u8, } /// Root of all flat (2D sensorium) world entities; hidden in material mode. #[derive(Component)] struct Flat2dRoot; /// Root of all material (3D) world entities; hidden in flat mode. #[derive(Component)] struct Real3dRoot; #[derive(Component)] struct RealCamera; /// Gizmo group for the material render, drawn only on the 3D camera's render /// layer so flat-mode gizmos and material-mode gizmos never cross cameras. #[derive(Default, Reflect, GizmoConfigGroup)] struct RealGizmos; /// Shared handles for the 3D scene's meshes plus a material cache keyed by /// (tile, fog, part, live): tiles swap between a small set of pooled /// materials instead of mutating thousands of unique ones every tick. The /// `live` bit is the emissive-state variant (flat-materials.md criterion 3): /// a device with a live feed to the player pools separately from the same /// device dark. #[derive(Resource, Default)] struct Materials3d { cache: HashMap<(TileType, u8, u8, bool), Handle>, } /// Pooled block meshes for the material render: full-height and cutaway /// variants per structural class. Blocks handle-swap between them as the /// south-face cutaway rule changes with fog (never per-entity mesh churn). #[derive(Resource, Default)] struct Meshes3d { wall: Handle, rock: Handle, door: Handle, /// Cutaway parapet, shared by wall and rock. wall_cut: Handle, door_cut: Handle, } fn fog_key(fog: Fog) -> u8 { match fog { Fog::Seen => 0, Fog::Heard => 1, Fog::Remembered => 2, Fog::Blueprint => 3, Fog::Unknown => 4, } } fn part_key(part: TilePart) -> u8 { match part { TilePart::Floor => 0, TilePart::Block => 1, TilePart::Prop => 2, TilePart::Top => 3, } } // ─── 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). Sim events /// keep their anchor: anchored lines are focus links (context-menu.md /// addendum — clicking one carries the cursor to the thing). log: Vec, /// The context menu on the focused anchor (wiki/interface/context-menu.md), /// when open. Only anchor + selection are held; rows are re-queried live. menu: Option, /// 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, } /// Open context-menu state (wiki/interface/context-menu.md). #[derive(Debug, Clone, Copy, PartialEq)] struct MenuState { anchor: Anchor, selected: usize, /// Window-pixel position to anchor the menu box at (the pointer, or the /// map cursor); `None` centers it (flow / unplaceable anchors). pos: Option, } impl Game { /// Live rows for the open menu — re-queried so legality is never stale. fn menu_rows(&self) -> Vec { self.menu .map(|m| menu_rows(&self.sim.available_actions(m.anchor))) .unwrap_or_default() } /// Stable key for the current anchor, so the menu UI knows when to /// rebuild its row buttons (vs. just refreshing text/selection). fn menu_anchor_key(&self) -> u64 { match self.menu.map(|m| m.anchor) { Some(Anchor::Tile { x, y }) => 1 << 60 | ((x as u32 as u64) << 20) | (y as u32 as u64), Some(Anchor::Device(id)) => 2 << 60 | id as u64, Some(Anchor::Person(id)) => 3 << 60 | id as u64, Some(Anchor::Flow(id)) => 4 << 60 | id as u64, None => 0, } } fn open_menu(&mut self, anchor: Anchor, pos: Option) { if menu_rows(&self.sim.available_actions(anchor)).is_empty() { // The feedback pulse (context-menu.md addendum): a seen anchor // answers instead of silence; fogged ground stays silent. if let Some(line) = self.sim.menu_empty_feedback(anchor) { let tick = self.sim.tick; self.add_log(tick, line); } } else { self.menu = Some(MenuState { anchor, selected: 0, pos, }); } } /// Focus an anchored log event (context-menu.md addendum): carry the /// cursor to the anchor — the camera follows it — and open its menu. /// A flow anchors to the ledger, not the map: its menu opens in place. /// An anchor the senses can no longer place is refused honestly. fn focus_event_anchor(&mut self, anchor: Anchor) { if let Anchor::Flow(_) = anchor { self.open_menu(anchor, None); return; } match self.sim.anchor_position(anchor) { Some((x, y)) => { self.set_cursor(x, y); self.sync_attendance(); self.open_menu(anchor, None); } None => { let tick = self.sim.tick; self.add_log(tick, "You can't place that right now."); } } } } 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(), menu: None, 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.add_event(LogEvent { tick, text: msg.to_string(), anchor: None, }); } fn add_event(&mut self, ev: LogEvent) { self.log.push(ev); let n = self.log.len(); if n > 100 { self.log.drain(..n - 100); } } fn drain(&mut self) { for ev in self.sim.drain_log_entries() { self.add_event(ev); } } 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")); } } #[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 SidebarScrollArea; #[derive(Component, Clone, Copy)] enum SidebarText { Header, Nudge, Focus, CycleStats, CycleRows, Core, Cover, Research, NetworkMoney, Footer, /// The clocks under the observer meters: next audit date, pilot strikes /// (detection.md criterion 3: the audit fires against a visible date). DetectionClocks, } #[derive(Component)] struct ComputeBarSegment { index: usize, } #[derive(Component, Clone, Copy)] struct DetectionText { row: usize, slot: DetectionSlot, } #[derive(Clone, Copy)] enum DetectionSlot { Label, Band, } #[derive(Component)] struct DetectionCell { row: usize, cell: usize, } #[derive(Component)] struct OverlayText; /// The context-menu root node (wiki/interface/context-menu.md), absolute and /// hidden until an anchor is focused. Its children are the whole card /// (title, rows, footer); rebuilds clear them via `despawn_related::` /// so chrome never stacks across opens. #[derive(Component)] struct MenuPanel; /// Title / footer chrome under `MenuPanel` (not a selectable row). #[derive(Component)] struct MenuChrome; /// One selectable menu row; `index` maps into the live `menu_rows()`. #[derive(Component)] struct MenuRowButton { index: usize, } /// One line of the RECENT TRACE card (context-menu.md addendum: /// event-to-anchor linking). A fixed window of rows, oldest first; /// clicking a row whose event carries an anchor focuses it. #[derive(Component)] struct LogRowButton { index: usize, } /// Rows shown in the RECENT TRACE card (the terminal shows six; the Bevy /// sidebar showed four before the rows became clickable — unchanged). const LOG_ROWS: usize = 4; /// Tracks what the menu UI was last rebuilt for, so rows are respawned only /// when the anchor or row count changes (selection/label refresh is cheap). #[derive(Resource, Default)] struct MenuUi { built: Option<(u64, usize)>, } type SidebarTextQuery<'w, 's> = Query<'w, 's, (&'static SidebarText, &'static mut Text), Without>; type ComputeBarQuery<'w, 's> = Query<'w, 's, (&'static ComputeBarSegment, &'static mut Node)>; type DetectionTextQuery<'w, 's> = Query<'w, 's, (&'static DetectionText, &'static mut Text), Without>; type DetectionCellQuery<'w, 's> = Query<'w, 's, (&'static DetectionCell, &'static mut BackgroundColor)>; type OverlayTextQuery<'w, 's> = Query< 'w, 's, &'static mut Text, ( With, Without, Without, ), >; type LogRowQuery<'w, 's> = Query< 'w, 's, ( &'static LogRowButton, &'static mut Text, &'static mut TextColor, ), ( Without, Without, Without, ), >; fn main() { let mut game = Game::new(); let mut mode = RenderMode::default(); // Dev screenshot harness: stage a deterministic scenario and capture one // PNG (see ShotHarness). Never active in normal play. let harness = std::env::var("MISALIGNED_SHOT").ok().map(|kind| { let path = std::env::var("MISALIGNED_SHOT_PATH") .unwrap_or_else(|_| format!("misaligned_shot_{kind}.png")); dev_shot_scenario(&mut game, &mut mode, &kind); ShotHarness { path, frames: 150, taken: false, } }); let mut app = App::new(); app.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))) // Low, cool ambient so the material render's point lights carve the room // out of darkness instead of flat-lighting it. 3D-only; sprites are unlit. .insert_resource(GlobalAmbientLight { color: Color::srgb(0.62, 0.70, 0.86), brightness: 55.0, ..default() }) .insert_resource(game) .insert_resource(mode) .insert_resource(Materials3d::default()) .init_resource::() .init_gizmo_group::() .add_systems(Startup, (setup, setup_ui, setup_3d).chain()) .add_systems( Update, ( ( handle_input, advance_sim, apply_render_mode, update_camera, update_camera_real, render_map, render_sensor_overlays, render_sensor_links, restyle_3d, flicker_feeds, render_real_links, render_cursor, ), ( render_people, render_people_3d, scroll_sidebar, render_ui, menu_pointer, log_row_pointer, manage_menu_ui, fog_audit_3d, shot_harness_system, ), ) .chain(), ); if let Some(harness) = harness { app.insert_resource(harness); } app.run(); } /// Stage the sim for a reviewable screenshot: earn eyes (scan, tap, splice /// every known device) and advance until a person stands inside camera /// coverage. Dev-only cheating (ops top-up) — this path exists purely to /// produce review PNGs of the renderer, never in normal play. fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.screen = Screen::Playing; game.paused = true; let core = game.sim.core_position(); game.set_cursor(core.0, core.1); let sim = &mut game.sim; sim.social_bandwidth = 500.0; sim.scan_network(); let ids: Vec = sim.reach.known().map(|d| d.id).collect(); for id in ids { sim.tap_device(id); sim.splice_device(id); sim.social_bandwidth = 500.0; } let visible_person = |sim: &Sim| { sim.people.people.iter().find_map(|p| { if !sim.can_see_person(p.id) { return None; } sim.person_pos(p.id).filter(|&(x, y)| sim.is_seen(x, y)) }) }; let mut person = visible_person(sim); for _ in 0..2000 { if person.is_some() { break; } sim.advance(); person = visible_person(sim); } game.drain(); match kind { "wide" => { mode.material = true; mode.zoom = 1.0; } "close" => { mode.material = true; mode.zoom = 0.32; if let Some((x, y)) = person { game.set_cursor(x, y); } } // Emissive-is-information check (flat-materials.md criterion 3): // park the cursor on the known device farthest from the core's // light rig and zoom close, so powered / dead / live-feed emissive // states are read in the dark, not under the fluorescents. "dark" => { mode.material = true; mode.zoom = 0.4; let core = game.sim.core_position(); let far = game .sim .reach .known() .map(|d| (d.x, d.y)) .filter(|&(dx, dy)| game.sim.is_seen(dx, dy)) .max_by_key(|&(dx, dy)| (dx - core.0).abs() + (dy - core.1).abs()); if let Some((x, y)) = far { game.set_cursor(x, y); } } // Framing-floor checks at the zoom bounds (material-render.md // criterion 5): min and max of update_camera_real's zoom clamp. "zoomin" => { mode.material = true; mode.zoom = 0.25; } "zoomout" => { mode.material = true; mode.zoom = 1.6; } _ => mode.material = false, } } /// Dev-harness fog-contract assertion (material-render.md criterion 4): /// unknown tiles carry no visible geometry, blueprint/remembered/heard /// surfaces are unlit model state, only seen surfaces participate in /// lighting, and people billboards exist only under earned coverage. Also /// tallies the material pool so per-tile material churn (criterion 6) would /// show up as an exploding cache. Runs once per screenshot run, prints the /// tally for the log, and panics on any violation so a capture cannot /// silently ship a fog leak. Dev-only; inert without MISALIGNED_SHOT. fn fog_audit_3d( harness: Option>, game: Res, mode: Res, cache: Res, mats: Res>, tiles: Query<( &Tile3d, &MeshMaterial3d, &InheritedVisibility, )>, people: Query<(&Person3d, &InheritedVisibility)>, ) { let Some(h) = harness else { return; }; // One exact frame, late enough that restyle_3d and visibility // propagation have settled. if h.frames != 30 || !mode.material { return; } let (mut checked, mut violations) = (0usize, Vec::new()); for (t, mat, vis) in tiles.iter() { checked += 1; let fog = game.sim.fog_at(t.x, t.y); if matches!(fog, Fog::Unknown) { if vis.get() { violations.push(format!("({}, {}) unknown but visible geometry", t.x, t.y)); } continue; } if !vis.get() { continue; } let Some(m) = mats.get(&mat.0) else { violations.push(format!("({}, {}) missing material", t.x, t.y)); continue; }; let should_be_lit = matches!(fog, Fog::Seen); if m.unlit == should_be_lit { violations.push(format!( "({}, {}) fog {fog:?} but unlit={} (seen must be lit, model state unlit)", t.x, t.y, m.unlit )); } } for (p, vis) in people.iter() { let earned = game.sim.can_see_person(p.id) && game .sim .person_pos(p.id) .is_some_and(|(x, y)| game.sim.is_seen(x, y)); if vis.get() && !earned { violations.push(format!("person {} visible without coverage", p.id)); } } // Flat-materials criterion 1: the world material set is solid color — // no image texture on any pooled world material, ever. for (key, handle) in cache.cache.iter() { let Some(m) = mats.get(handle) else { continue }; if m.base_color_texture.is_some() || m.emissive_texture.is_some() { violations.push(format!( "world material {key:?} references an image texture" )); } } assert!( violations.is_empty(), "fog audit FAILED:\n{}", violations.join("\n") ); println!( "fog audit OK: {checked} tile entities (unknown=absent, model=unlit, seen=lit), \ {} people billboards coverage-gated, material pool {} handles, all flat (no textures)", people.iter().count(), cache.cache.len() ); } /// Countdown, capture, countdown, exit: gives the asset loads and the camera /// lerp time to settle before the PNG is taken. fn shot_harness_system( mut commands: Commands, harness: Option>, mut exit: MessageWriter, ) { let Some(mut h) = harness else { return; }; if h.frames > 0 { h.frames -= 1; return; } if !h.taken { h.taken = true; h.frames = 90; let path = std::path::PathBuf::from(&h.path); commands .spawn(Screenshot::primary_window()) .observe(save_to_disk(path)); } else { exit.write(AppExit::Success); } } 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, mut game: ResMut, harness: Option>) { let (w, h) = (game.sim.map.width, game.sim.map.height); // A screenshot scenario may have staged its own cursor anchor // (dev_shot_scenario runs before the app starts); don't clobber it. if harness.is_none() { let core = game.sim.core_position(); game.set_cursor(core.0, core.1); } let start = (game.cursor_x, game.cursor_y); commands.spawn(( Camera2d, // The 2D camera always renders (it carries the UI); in material mode // apply_render_mode stops it clearing so the 3D frame shows through. IsDefaultUiCamera, Projection::from(OrthographicProjection { scale: 0.42, ..OrthographicProjection::default_2d() }), Transform::from_translation(grid_to_world(start.0, start.1, 1000.0)), GameCamera, )); // Every flat-render world entity hangs off this root so the F3 material // toggle can hide the whole 2D dialect at once. let flat_root = commands .spawn((Transform::default(), Visibility::Visible, Flat2dRoot)) .id(); // 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, )), ChildOf(flat_root), )); 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 }, ChildOf(flat_root), )); 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 }, ChildOf(flat_root), )); } } } // 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 }, ChildOf(flat_root), )); } // One marker per person: glyph from Sim::person_glyph (initial once // identified, '?' while Unknown), shown only inside sensor coverage. for p in &game.sim.people.people { let glyph = game.sim.person_glyph(p.id).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 }, ChildOf(flat_root), )); } game.drain(); } // ─── Material render (HD-2D default physical view) ─────────────────────────── /// Grid tile to 3D world position in the material render: one unit per tile, /// +X east, +Z south (same handedness as the 2D map), +Y up. Same anchors as /// the flat render, different scale. fn grid_to_world_3d(x: i32, y: i32, h: f32) -> Vec3 { Vec3::new(x as f32 + 0.5, h, y as f32 + 0.5) } /// A tiny procedural bone-white silhouette: people under camera coverage are /// billboards, never detailed art the sensor has not earned (views.md). fn person_silhouette_image() -> Image { const ROWS: [&str; 18] = [ "....####....", "...######...", "...######...", "...######...", "....####....", ".....##.....", "..########..", ".##########.", ".##########.", ".##.####.##.", ".##.####.##.", "....####....", "....####....", "....#..#....", "...##..##...", "...##..##...", "...##..##...", "..###..###..", ]; let (w, h) = (ROWS[0].len(), ROWS.len()); let mut data = vec![0u8; w * h * 4]; for (y, row) in ROWS.iter().enumerate() { for (x, c) in row.chars().enumerate() { if c == '#' { let i = (y * w + x) * 4; data[i..i + 4].copy_from_slice(&[219, 216, 209, 255]); } } } Image::new( Extent3d { width: w as u32, height: h as u32, depth_or_array_layers: 1, }, TextureDimension::D2, data, TextureFormat::Rgba8UnormSrgb, RenderAssetUsages::RENDER_WORLD | RenderAssetUsages::MAIN_WORLD, ) } /// The sim-known tile at a coordinate under fog: remembered coordinates /// render their remembered snapshot, everything else the live map. fn known_tile(game: &Game, x: i32, y: i32) -> TileType { match game.sim.fog_at(x, y) { Fog::Remembered => game .sim .remembered .get(&(x, y)) .map(|m| m.tile) .unwrap_or_else(|| game.sim.map.get_tile(x, y)), _ => game.sim.map.get_tile(x, y), } } /// South-face cutaway rule (material-render.md debt 1): a structural block /// drops to parapet height when the tile it hides from the pitched camera /// (its north neighbor, y - 1, farther from the south-anchored camera) is /// KNOWN open interior. Gated on the neighbor being known: an unknown /// neighbor keeps full height, so the cutaway itself never leaks whether /// unscouted space behind a wall is open (legibility law). fn cutaway(game: &Game, x: i32, y: i32) -> bool { y > 0 && !matches!(game.sim.fog_at(x, y - 1), Fog::Unknown) && !blocky_tile(known_tile(game, x, y - 1)) } /// Block height in the material render for a structural tile. fn block_height(tile: TileType, cut: bool) -> f32 { match (tile, cut) { (TileType::Rock, false) => ROCK_HEIGHT, (TileType::Wall, false) => WALL_HEIGHT, (TileType::Rock | TileType::Wall, true) => CUTAWAY_HEIGHT, (_, false) => WALL_HEIGHT * 0.9, (_, true) => DOOR_CUT_HEIGHT, } } /// Emissive glow for a Seen prop — emissive is information, never /// decoration (flat-materials.md criterion 3): the core reads brightest (it /// is you), powered machines carry amber machine presence, power /// infrastructure carries the cold signal, a live feed to the player scans /// cold on the device, and dead equipment stays dark. fn prop_emissive(tile: TileType, live: bool) -> LinearRgba { use TileType::*; if live { // Live feed: the cold signal, bright enough to read in the dark. // flicker_feeds breathes this value on the pooled handle. return SIGNAL.to_linear() * 0.9; } match tile { Core => AMBER.to_linear() * 1.6, Rack | Switch | PatchPanel => AMBER.to_linear() * 0.22, HvacUnit => AMBER.to_linear() * 0.05, Ups | PowerCore | BreakerPanel => SIGNAL.to_linear() * 0.18, // Cameras without a feed to you, dead equipment, furniture: dark. _ => LinearRgba::BLACK, } } /// Build the material for one (tile, fog, part, live) combination: a solid /// palette color under light — no image textures anywhere in the world /// render (flat-materials.md criterion 1). The epistemic rule is carried by /// lighting participation: only Seen surfaces are lit (they are camera /// truth); remembered/blueprint/heard surfaces are unlit model state, so /// the fog contract stays legible in 3D. Emissive exists only on Seen /// surfaces — model state never glows. fn material_3d(tile: TileType, fog: Fog, part: TilePart, live: bool) -> StandardMaterial { let mut m = StandardMaterial { perceptual_roughness: 0.94, metallic: 0.0, reflectance: 0.08, ..default() }; match part { TilePart::Floor => match fog { Fog::Seen => { // Cable runs / conduit read as amber-warm service floor // (machine presence underfoot); everything else is lab // porcelain under the light rig. m.base_color = family_color(tile); } Fog::Heard => { // Dim warm presence: sound earned a floor, nothing more. m.unlit = true; m.base_color = scaled(FLOOR_SERVICE, 0.17); } Fog::Remembered => { m.unlit = true; m.base_color = scaled(family_color(tile), 0.45); } _ => { // Blueprint: schematic plan surface. Lifted slightly from the // 2D palette so the learned floor plan reads at 3D distance. m.unlit = true; let c = blueprint_color(tile).to_srgba(); m.base_color = Color::srgb(c.red + 0.05, c.green + 0.055, c.blue + 0.07); } }, TilePart::Block => match fog { Fog::Seen => { m.base_color = family_color(tile); } Fog::Remembered => { m.unlit = true; m.base_color = remembered_tint(tile); } _ => { // Blueprint mass: schematic ghost volume. m.unlit = true; m.base_color = if danger_tile(tile) { scaled(CRIMSON, 0.26) } else { scaled(GUNMETAL, 0.50) }; } }, TilePart::Top => { // Dedicated cap treatment (material-render.md debt 2): the block // family darkened per structural class, so tops read as poured // caps / rock crowns, not the side color smeared flat. let cap = { use TileType::*; match tile { Rock => scaled(GUNMETAL, 0.27), Wall => scaled(GUNMETAL, 0.90), SecurityDoor3 | SealedDoor | RollDoor => scaled(SECURITY, 0.85), _ => scaled(DOOR_SLAB, 0.56), // door slabs: worn plate } }; match fog { Fog::Seen => { m.base_color = cap; } Fog::Remembered => { m.unlit = true; m.base_color = scaled(cap, 0.55); } _ => { // Blueprint: schematic cap over the ghost mass. m.unlit = true; m.base_color = if danger_tile(tile) { scaled(CRIMSON, 0.21) } else { scaled(GUNMETAL, 0.40) }; } } } TilePart::Prop => { m.double_sided = true; m.cull_mode = None; match fog { Fog::Seen => { // Flat chassis color; identity comes from the panel, and // machine presence from emissive plus the point lights, // never from decorative detail. m.base_color = family_color(tile); m.emissive = prop_emissive(tile, live); } Fog::Remembered => { m.unlit = true; m.base_color = remembered_tint(tile); } _ => { m.unlit = true; m.base_color = if machine_tile(tile) { scaled(AMBER_DIM, 0.43) } else if danger_tile(tile) { scaled(CRIMSON, 0.28) } else { scaled(GUNMETAL, 0.42) }; } } } } m } fn pooled_material( tile: TileType, fog: Fog, part: TilePart, live: bool, mats: &mut Assets, cache: &mut Materials3d, ) -> Handle { cache .cache .entry((tile, fog_key(fog), part_key(part), live)) .or_insert_with(|| mats.add(material_3d(tile, fog, part, live))) .clone() } /// Spawn the material-render scene: tilted camera, per-tile floor planes, /// extruded wall/door boxes with dedicated top caps, prop and person /// billboards, and the point-light rig. All of it lives on render layer 1 /// under one root that F3 toggles against the flat sensorium root. fn setup_3d( mut commands: Commands, game: Res, mode: Res, mut meshes: ResMut>, mut images: ResMut>, mut materials: ResMut>, mut config_store: ResMut, ) { let layer = RenderLayers::layer(1); let (config, _) = config_store.config_mut::(); config.render_layers = layer.clone(); config.line.width = 2.0; let (w, h) = (game.sim.map.width, game.sim.map.height); let core = game.sim.core_position(); let root = commands .spawn(( Transform::default(), if mode.material { Visibility::Visible } else { Visibility::Hidden }, Real3dRoot, )) .id(); // The material camera: same anchor (the cursor tile) as the flat camera, // pitched down into the room. Renders before the 2D camera, which stops // clearing in material mode so the UI composites on top. let target = grid_to_world_3d(game.cursor_x, game.cursor_y, 0.0); let tilt = CAMERA_TILT_DEG.to_radians(); commands.spawn(( Camera3d::default(), Camera { is_active: mode.material, order: -1, ..default() }, Projection::from(PerspectiveProjection { fov: 35.0_f32.to_radians(), ..default() }), Transform::from_translation(target + Vec3::new(0.0, tilt.sin(), tilt.cos()) * 24.0) .looking_at(target, Vec3::Y), layer.clone(), RealCamera, )); let floor_mesh = meshes.add(Plane3d::default().mesh().size(1.0, 1.0)); let top_mesh = meshes.add(Plane3d::default().mesh().size(1.0, 1.0)); let prop_mesh = meshes.add(Rectangle::new(0.95, 0.95)); let person_mesh = meshes.add(Rectangle::new(0.62, 1.0)); let mesh_pool = Meshes3d { wall: meshes.add(Cuboid::new(1.0, WALL_HEIGHT, 1.0)), rock: meshes.add(Cuboid::new(1.0, ROCK_HEIGHT, 1.0)), door: meshes.add(Cuboid::new(0.98, WALL_HEIGHT * 0.9, 0.4)), wall_cut: meshes.add(Cuboid::new(1.0, CUTAWAY_HEIGHT, 1.0)), door_cut: meshes.add(Cuboid::new(0.98, DOOR_CUT_HEIGHT, 0.4)), }; let placeholder = materials.add(StandardMaterial { base_color: Color::BLACK, unlit: true, ..default() }); for y in 0..h { for x in 0..w { let tile = game.sim.map.get_tile(x, y); if blocky_tile(tile) { // Structural mass: one box per tile; restyle_3d swaps between // the full and cutaway mesh variants as fog earns interiors. let mesh = match tile { TileType::Rock => mesh_pool.rock.clone(), TileType::Wall => mesh_pool.wall.clone(), _ => mesh_pool.door.clone(), }; commands.spawn(( Mesh3d(mesh), MeshMaterial3d(placeholder.clone()), Transform::from_translation(grid_to_world_3d( x, y, block_height(tile, false) / 2.0, )), Visibility::Hidden, layer.clone(), Tile3d { x, y, part: TilePart::Block, }, ChildOf(root), )); // Dedicated top cap over the box (material-render.md debt 2): // sides keep the structural family material, while the top // gets its own treatment instead of a smeared slab. let cap_scale = if matches!(tile, TileType::Rock | TileType::Wall) { Vec3::ONE } else { Vec3::new(0.98, 1.0, 0.4) }; commands.spawn(( Mesh3d(top_mesh.clone()), MeshMaterial3d(placeholder.clone()), Transform::from_translation(grid_to_world_3d( x, y, block_height(tile, false) + 0.002, )) .with_scale(cap_scale), Visibility::Hidden, layer.clone(), Tile3d { x, y, part: TilePart::Top, }, ChildOf(root), )); } else { commands.spawn(( Mesh3d(floor_mesh.clone()), MeshMaterial3d(placeholder.clone()), Transform::from_translation(grid_to_world_3d(x, y, 0.0)), Visibility::Hidden, layer.clone(), Tile3d { x, y, part: TilePart::Floor, }, ChildOf(root), )); // Every walkable tile gets a (usually hidden) billboard so // bought racks and salvage changes appear without respawning. commands.spawn(( Mesh3d(prop_mesh.clone()), MeshMaterial3d(placeholder.clone()), Transform::from_translation(grid_to_world_3d(x, y, 0.475)), Visibility::Hidden, layer.clone(), Tile3d { x, y, part: TilePart::Prop, }, ChildOf(root), )); } } } // People: upright billboarded silhouettes, shown only under earned // camera coverage (same rule as the flat render). let silhouette = images.add(person_silhouette_image()); let person_mat = materials.add(StandardMaterial { base_color: BONE, base_color_texture: Some(silhouette), alpha_mode: AlphaMode::Mask(0.5), double_sided: true, cull_mode: None, perceptual_roughness: 0.9, ..default() }); for p in &game.sim.people.people { commands.spawn(( Mesh3d(person_mesh.clone()), MeshMaterial3d(person_mat.clone()), Transform::from_translation(grid_to_world_3d(0, 0, 0.5)), Visibility::Hidden, layer.clone(), Person3d { id: p.id }, ChildOf(root), )); } // The light rig (three points + the low global ambient): // 1) cold fluorescent overhead — the basement's own institutional light; commands.spawn(( PointLight { color: Color::srgb(0.80, 0.88, 1.0), intensity: 2_200_000.0, range: 34.0, shadows_enabled: true, ..default() }, Transform::from_translation( grid_to_world_3d(core.0, core.1, 0.0) + Vec3::new(0.5, 6.5, 1.0), ), layer.clone(), ChildOf(root), )); // 2) warm amber machine glow at the core rack (machine presence); commands.spawn(( PointLight { color: AMBER, intensity: 320_000.0, range: 9.0, shadows_enabled: false, ..default() }, Transform::from_translation( grid_to_world_3d(core.0, core.1, 0.0) + Vec3::new(0.0, 1.5, 0.6), ), layer.clone(), ChildOf(root), )); // 3) a second amber glow at the nearest known device anchor away from the // core (device anchors come from the start-of-game blueprint, so this // lights no unearned geometry). let second = game .sim .reach .known() .map(|d| (d.x, d.y)) .filter(|&(dx, dy)| (dx - core.0).abs() + (dy - core.1).abs() >= 4) .min_by_key(|&(dx, dy)| (dx - core.0).abs() + (dy - core.1).abs()); if let Some((sx, sy)) = second { commands.spawn(( PointLight { color: AMBER, intensity: 200_000.0, range: 7.0, shadows_enabled: false, ..default() }, Transform::from_translation(grid_to_world_3d(sx, sy, 0.0) + Vec3::new(0.0, 1.3, 0.4)), layer, ChildOf(root), )); } commands.insert_resource(mesh_pool); } /// Apply the F3 toggle: activate the 3D camera, stop/resume the 2D camera's /// clear, and swap which world root is visible. Runs only when the mode /// resource actually changed. #[allow(clippy::type_complexity)] fn apply_render_mode( mode: Res, mut cams: ParamSet<( Query<&mut Camera, With>, Query<&mut Camera, With>, )>, mut roots: ParamSet<( Query<&mut Visibility, With>, Query<&mut Visibility, With>, )>, ) { if !mode.is_changed() { return; } if let Ok(mut c) = cams.p0().single_mut() { c.clear_color = if mode.material { ClearColorConfig::None } else { ClearColorConfig::Default }; } if let Ok(mut c) = cams.p1().single_mut() { c.is_active = mode.material; } if let Ok(mut v) = roots.p0().single_mut() { *v = if mode.material { Visibility::Hidden } else { Visibility::Visible }; } if let Ok(mut v) = roots.p1().single_mut() { *v = if mode.material { Visibility::Visible } else { Visibility::Hidden }; } } /// Restyle the 3D scene from sim facts: same fog precedence as render_map, /// projected into visibility + pooled materials/meshes instead of sprite /// tints. Unknown space stays geometry-free — sensor darkness, not hidden /// art. Structural blocks swap to the cutaway parapet mesh when they would /// hide known interior from the pitched camera (south-face occlusion, /// material-render.md debt 1), and their top caps ride the same height. fn restyle_3d( game: Res, mut mode: ResMut, pool: Res, mut mats: ResMut>, mut cache: ResMut, mut q: Query<( &Tile3d, &mut Mesh3d, &mut MeshMaterial3d, &mut Transform, &mut Visibility, )>, ) { if !mode.material { return; } if !game.is_changed() && !mode.dirty { return; } mode.dirty = false; for (t, mut mesh, mut mat, mut tf, mut vis) in q.iter_mut() { let fog = game.sim.fog_at(t.x, t.y); let tile = known_tile(&game, t.x, t.y); let show = match (t.part, fog) { (_, Fog::Unknown) => false, // Heard earns presence only: a dim floor, never shape. (TilePart::Floor, _) => true, (TilePart::Block | TilePart::Top, Fog::Heard) => false, (TilePart::Block | TilePart::Top, _) => blocky_tile(tile), (TilePart::Prop, Fog::Heard) => false, (TilePart::Prop, _) => prop_tile(tile), }; if !show { *vis = Visibility::Hidden; continue; } *vis = Visibility::Inherited; if matches!(t.part, TilePart::Block | TilePart::Top) { let cut = cutaway(&game, t.x, t.y); let height = block_height(tile, cut); if t.part == TilePart::Block { let target = match (tile, cut) { (TileType::Rock, false) => &pool.rock, (TileType::Wall, false) => &pool.wall, (TileType::Rock | TileType::Wall, true) => &pool.wall_cut, (_, false) => &pool.door, (_, true) => &pool.door_cut, }; if mesh.0 != *target { mesh.0 = target.clone(); } tf.translation.y = height / 2.0; } else { tf.translation.y = height + 0.002; } } // Live-feed emissive state (flat-materials.md criterion 3): a seen // device actively feeding the player scans in the cold signal color. let live = t.part == TilePart::Prop && matches!(fog, Fog::Seen) && game.sim.reach.known_at(t.x, t.y).is_some_and(|d| { d.feed_to(Party::Player, true) || d.feed_to(Party::Player, false) }); let handle = pooled_material(tile, fog, t.part, live, &mut mats, &mut cache); if mat.0 != handle { mat.0 = handle; } } } /// Scan flicker on live feeds (flat-materials.md: functional shader-level /// detail only): the pooled live-feed materials' cold-signal emissive /// breathes, so a device actively feeding the player reads as a running /// scan, not a static lamp. Mutates only the handful of live-feed pool /// handles — never per-tile materials. fn flicker_feeds( time: Res