From b9f6086d35175fbc8828819d6d29720737102588 Mon Sep 17 00:00:00 2001 From: Cameron Date: Tue, 7 Jul 2026 17:53:34 -0700 Subject: [PATCH] Land HD-2D material render toggle. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Defense: This is the real/camera dialect promised by the same-frame digital/real canvas: it is a frontend-only projection over the same sim anchors, fog, cursor, reach overlays, UI, and finance panel, with no new save state or Bevy-only knowledge. Keeping the flat sensorium as default and putting material render behind F3 lets the art direction progress without breaking terminal parity or turning the physical view into a separate mode. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- DESIGN.md | 11 + README.md | 1 + src/bin/bevy.rs | 1042 +++++++++++++++++++- wiki/interface/bevy-digital-real-canvas.md | 69 +- wiki/interface/bevy.md | 30 +- wiki/log/2026-07-07-hd2d-prototype.md | 89 ++ wiki/log/DEVLOG.md | 21 + wiki/process/specs.md | 2 +- 8 files changed, 1179 insertions(+), 86 deletions(-) create mode 100644 wiki/log/2026-07-07-hd2d-prototype.md diff --git a/DESIGN.md b/DESIGN.md index 7b65a977..a50b83d7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1581,6 +1581,17 @@ knows."* board for digital, making the default/home view a literal 3D room, and full free-camera 3D (scope/asset/readability cost). Spec: wiki/interface/bevy-digital-real-canvas.md. +- **2026-07-07 — HD-2D material render accepted as a staging preview.** + Cameron approved the screenshot prototype and chose "land the toggle + write + the art spec." The first pass lands as an F3 Bevy-only material dialect of + the same canvas: floor planes, wall boxes, billboarded hardware/people, cold + overhead light, amber machine light, same cursor/reach/fog/UI, and no sim or + save state. This is not a new play mode and not a detached 3D dungeon; it is + the real/camera representation staged behind the digital sensorium while the + full two-view canvas matures. Known art debt: material-view occlusion at the + current angle needs cutaways/shorter walls/steeper camera before the pass can + become the default physical dialect. Spec: + wiki/interface/bevy-digital-real-canvas.md. - **2026-07-07 — Bevy visual floor before new art.** The graphical build has to look intentional before the Pixel Lab backlog clears: no postage-stamp known map in empty black, no terminal dump pasted into a sidebar, and no diff --git a/README.md b/README.md index 6c64ac55..94c7e3ee 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ it resolves `assets/` from the project root (or set `BEVY_ASSET_ROOT`). | `1`-`4` | Shift compute allocation (day job / conceal / social / research) | | `t` | People panel | | `[` / `]` | Zoom (Bevy) | +| `F3` | Toggle Bevy HD-2D material preview | | `Ctrl+S` / `Ctrl+L` | Save / load | | `q` | Quit | diff --git a/src/bin/bevy.rs b/src/bin/bevy.rs index 5351084a..80febea6 100644 --- a/src/bin/bevy.rs +++ b/src/bin/bevy.rs @@ -8,7 +8,11 @@ use std::collections::HashMap; +use bevy::asset::RenderAssetUsages; +use bevy::camera::visibility::RenderLayers; use bevy::prelude::*; +use bevy::render::render_resource::{Extent3d, TextureDimension, TextureFormat}; +use bevy::render::view::screenshot::{Screenshot, save_to_disk}; use bevy::window::{PrimaryWindow, WindowResolution}; use misaligned::detection::Band; use misaligned::machine::Channel; @@ -20,6 +24,13 @@ use misaligned::tiles::TileType; const TILE_SIZE: f32 = 16.0; const SIDEBAR_WIDTH: f32 = 340.0; +// HD-2D prototype (F3 material render): one 3D world unit per map tile. +const WALL_HEIGHT: f32 = 1.5; +const ROCK_HEIGHT: f32 = 2.0; +/// 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; + /// 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); @@ -39,11 +50,15 @@ const CRIMSON: Color = Color::srgb(0.84, 0.15, 0.15); #[derive(Resource)] struct Art { tiles: HashMap>, + /// Bare concrete used as the ground plane under props in the material + /// (HD-2D) render. + concrete: Handle, } impl Art { fn load(assets: &AssetServer) -> Self { let t = |n: &str| assets.load(format!("pixellab/tiles/{n}.png")); + let concrete = t("b1_concrete"); let mut tiles = HashMap::new(); tiles.insert(TileType::Floor, t("b1_floor")); tiles.insert(TileType::Wall, t("b1_wall")); @@ -60,7 +75,7 @@ impl Art { tiles.insert(TileType::SecurityDoor1, t("door")); tiles.insert(TileType::SecurityDoor2, t("door")); tiles.insert(TileType::SecurityDoor3, t("door")); - Self { tiles } + Self { tiles, concrete } } } @@ -108,6 +123,44 @@ fn danger_tile(tile: TileType) -> bool { ) } +/// 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 billboarded quads (their existing sprites or a flat +/// stand-in color) 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 + ) +} + fn seen_tint(tile: TileType) -> Color { use TileType::*; match tile { @@ -159,6 +212,109 @@ fn unknown_color(x: i32, y: i32) -> Color { } } +// ─── Render mode (HD-2D prototype) ─────────────────────────────────────────── + +/// 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 == false` (default) is the current flat sensorium render. +/// `material == true` is the HD-2D prototype: a tilted Camera3d over the same +/// tile grid, floor planes, extruded wall boxes, billboarded props/people, and +/// a few point lights. +#[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: false, + dirty: true, + zoom: 1.0, + } + } +} + +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|wide|close`, path via +/// `MISALIGNED_SHOT_PATH`): stages a scenario, waits for assets, saves one +/// screenshot, exits. Not a player surface; exists so render experiments 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)] +enum TilePart { + Floor, + Block, + 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): tiles swap between a small set of pooled materials +/// instead of mutating thousands of unique ones every tick. +#[derive(Resource, Default)] +struct Materials3d { + cache: HashMap<(TileType, u8, u8), 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, + } +} + // ─── App state ─────────────────────────────────────────────────────────────── #[derive(PartialEq, Clone, Copy, Debug)] @@ -321,41 +477,150 @@ struct FinancePanel; struct FinancePanelText; fn main() { - App::new() - .add_plugins( - DefaultPlugins - .set(WindowPlugin { - primary_window: Some(Window { - title: "Misaligned".to_string(), - resolution: WindowResolution::new(1280, 720), - ..default() - }), + 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() - }) - .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, - render_finance_panel, - ) - .chain(), + }), + ..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_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, + render_real_links, + render_cursor, + render_people, + render_people_3d, + render_ui, + render_people_panel, + render_reach_panel, + render_finance_panel, + shot_harness_system, ) - .run(); + .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 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); + } + } + _ => mode.material = false, + } +} + +/// 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 { @@ -374,6 +639,9 @@ fn setup(mut commands: Commands, assets: Res, mut game: ResMut, mut game: ResMut, mut game: ResMut, mut game: ResMut, mut game: ResMut, mut game: ResMut, mut game: ResMut, mut game: ResMut 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, + ) +} + +/// Ground texture under a tile in the material render. +fn floor_texture(tile: TileType, art: &Art) -> Option> { + use TileType::*; + match tile { + Floor | FloorDrain | Entry => art.tiles.get(&Floor).cloned(), + _ => Some(art.concrete.clone()), + } +} + +/// Build the material for one (tile, fog, part) combination. 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. +fn material_3d(tile: TileType, fog: Fog, part: TilePart, art: &Art) -> StandardMaterial { + let mut m = StandardMaterial { + perceptual_roughness: 0.94, + metallic: 0.0, + reflectance: 0.08, + ..default() + }; + match part { + TilePart::Floor => match fog { + Fog::Seen => { + m.base_color_texture = floor_texture(tile, art); + m.base_color = if machine_tile(tile) { + // Cable runs / conduit read as amber-warm service floor. + Color::srgb(0.95, 0.80, 0.52) + } else { + Color::srgb(0.80, 0.81, 0.78) + }; + } + Fog::Heard => { + m.unlit = true; + m.base_color = Color::srgb(0.10, 0.10, 0.075); + } + Fog::Remembered => { + m.unlit = true; + m.base_color_texture = floor_texture(tile, art); + m.base_color = Color::srgb(0.34, 0.33, 0.35); + } + _ => { + // 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 => { + let tex = art.tiles.get(&tile).cloned(); + match fog { + Fog::Seen => { + m.base_color_texture = tex; + m.base_color = seen_tint(tile); + } + Fog::Remembered => { + m.unlit = true; + m.base_color_texture = tex; + let c = remembered_tint(tile).to_srgba(); + m.base_color = Color::srgb(c.red, c.green, c.blue); + } + _ => { + // Blueprint mass: schematic ghost volume, no live texture. + m.unlit = true; + m.base_color = if danger_tile(tile) { + Color::srgb(0.22, 0.09, 0.09) + } else { + Color::srgb(0.15, 0.17, 0.21) + }; + } + } + } + TilePart::Prop => { + let tex = art.tiles.get(&tile).cloned(); + m.double_sided = true; + m.cull_mode = None; + m.alpha_mode = AlphaMode::Mask(0.5); + match fog { + Fog::Seen => { + // Near-white base so the sprite's own pixels carry the + // detail; machine presence comes from the amber emissive + // plus the point lights, not from tinting the whole card. + m.base_color = if tex.is_some() { + Color::srgb(0.92, 0.90, 0.85) + } else { + tile_color(tile) + }; + m.base_color_texture = tex; + if machine_tile(tile) { + // Amber emissive is machine presence only. + m.emissive = AMBER.to_linear() * 0.14; + } + } + Fog::Remembered => { + m.unlit = true; + let c = remembered_tint(tile).to_srgba(); + m.base_color = Color::srgb(c.red, c.green, c.blue); + m.base_color_texture = tex; + } + _ => { + m.unlit = true; + m.base_color = if machine_tile(tile) { + Color::srgb(0.30, 0.22, 0.08) + } else if danger_tile(tile) { + Color::srgb(0.24, 0.09, 0.09) + } else { + Color::srgb(0.14, 0.15, 0.16) + }; + } + } + } + } + m +} + +fn pooled_material( + tile: TileType, + fog: Fog, + part: TilePart, + art: &Art, + mats: &mut Assets, + cache: &mut Materials3d, +) -> Handle { + cache + .cache + .entry((tile, fog_key(fog), part_key(part))) + .or_insert_with(|| mats.add(material_3d(tile, fog, part, art))) + .clone() +} + +/// Spawn the material-render scene: tilted camera, per-tile floor planes, +/// extruded wall/door boxes, prop and person billboards, and the point-light +/// rig. All of it lives on render layer 1 under one root, hidden until F3. +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 wall_mesh = meshes.add(Cuboid::new(1.0, WALL_HEIGHT, 1.0)); + let rock_mesh = meshes.add(Cuboid::new(1.0, ROCK_HEIGHT, 1.0)); + let door_mesh = meshes.add(Cuboid::new(0.98, WALL_HEIGHT * 0.9, 0.4)); + let prop_mesh = meshes.add(Rectangle::new(0.95, 0.95)); + let person_mesh = meshes.add(Rectangle::new(0.62, 1.0)); + 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 never mutates at runtime; one box per tile. + let (mesh, half) = match tile { + TileType::Rock => (rock_mesh.clone(), ROCK_HEIGHT / 2.0), + TileType::Wall => (wall_mesh.clone(), WALL_HEIGHT / 2.0), + _ => (door_mesh.clone(), WALL_HEIGHT * 0.45), + }; + commands.spawn(( + Mesh3d(mesh), + MeshMaterial3d(placeholder.clone()), + Transform::from_translation(grid_to_world_3d(x, y, half)), + Visibility::Hidden, + layer.clone(), + Tile3d { + x, + y, + part: TilePart::Block, + }, + 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), + )); + } +} + +/// 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 instead of sprite tints. +/// Unknown space stays geometry-free — sensor darkness, not hidden art. +fn restyle_3d( + game: Res, + mut mode: ResMut, + art: Res, + mut mats: ResMut>, + mut cache: ResMut, + mut q: Query<( + &Tile3d, + &mut MeshMaterial3d, + &mut Visibility, + )>, +) { + if !mode.material { + return; + } + if !game.is_changed() && !mode.dirty { + return; + } + mode.dirty = false; + for (t, mut mat, mut vis) in q.iter_mut() { + let fog = game.sim.fog_at(t.x, t.y); + let tile = match fog { + Fog::Remembered => game + .sim + .remembered + .get(&(t.x, t.y)) + .map(|m| m.tile) + .unwrap_or_else(|| game.sim.map.get_tile(t.x, t.y)), + _ => game.sim.map.get_tile(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, Fog::Heard) => false, + (TilePart::Block, _) => blocky_tile(tile), + (TilePart::Prop, Fog::Heard) => false, + (TilePart::Prop, _) => prop_tile(tile), + }; + if !show { + *vis = Visibility::Hidden; + continue; + } + *vis = Visibility::Inherited; + let handle = pooled_material(tile, fog, t.part, &art, &mut mats, &mut cache); + if mat.0 != handle { + mat.0 = handle; + } + } +} + +/// The material camera: anchored to the cursor tile like the flat camera, +/// framed to the known-content extent (bevy-visual-floor.md: no +/// postage-stamp map), zoomable with [ ]. +fn update_camera_real( + time: Res