diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index 222672df..9b91485e 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -25,7 +25,7 @@ use misaligned::actions::{ActionCommand, ActionKind, Anchor, DialId, HumanMenuRo use misaligned::detection::{Band, SignatureKind}; use misaligned::hall::RackSite; use misaligned::person::{Knowledge, ScheduleBlock}; -use misaligned::reach::Party; +use misaligned::reach::{Device, Party}; use misaligned::sim::{Fog, HeardKind, LogEvent, Sim, TraceDebtStatus}; use misaligned::tiles::TileType; use misaligned::ui_projection::fact_source_label; @@ -387,22 +387,22 @@ fn flat_seen_color(tile: TileType) -> Color { } } +/// DIGITAL separates the learned floor field from objects placed on it. +/// Props and machines get their own silhouettes; painting their entire tile +/// turned the first Seen frame into a teal/amber checkerboard. +fn flat_seen_ground_color(sim: &Sim, x: i32, y: i32, tile: TileType) -> Color { + if sim.rack_site_at(x, y).is_some() || sim.reach.known_at(x, y).is_some() { + scaled(family_color(TileType::Floor), 0.62) + } else { + flat_seen_color(tile) + } +} + 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) } -fn flat_rack_color(sim: &Sim, x: i32, y: i32) -> Option { - Some(match sim.rack_site_at(x, y)? { - RackSite::OwnedMachine { .. } => BONE, - RackSite::Commissionable if sim.feel_floor_is_earned() => scaled(SIGNAL, 0.58), - RackSite::Commissionable => scaled(GUNMETAL, 0.42), - RackSite::Foreign { powered: true } => scaled(SIGNAL, 0.42), - RackSite::Foreign { powered: false } => scaled(GUNMETAL_DARK, 0.76), - RackSite::Dead => scaled(NEAR_BLACK, 0.72), - }) -} - /// Remembered (model-state) treatment for 3D blocks and props: the family /// language survives, desaturated toward the unlit snapshot. fn remembered_tint(tile: TileType) -> Color { @@ -510,8 +510,8 @@ enum RailSection { Secondary, } -/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|wide|close|dark| -/// zoomin|zoomout|intel|tokens|thoughtflow|visual-proof|person-proof|signal|ears|eyes-white|eyes-form| +/// Dev screenshot harness (env `MISALIGNED_SHOT=flat|hall|hall-material|opening|opening-digital|wide|close|dark| +/// zoomin|zoomout|intel|tokens|thoughtflow|visual-proof|person-proof|signal|ears|ears-digital|eyes-white|eyes-form| /// hover-menu|menu|recruit-menu|worklight|worklightoff`, path via /// `MISALIGNED_SHOT_PATH`): stages a scenario, /// waits for @@ -1210,6 +1210,20 @@ struct CursorMarker { offset: Vec2, } #[derive(Component)] +struct DigitalFocusLabel; +type CursorMarkerQuery<'w, 's> = + Query<'w, 's, (&'static CursorMarker, &'static mut Transform), Without>; +type DigitalFocusLabelQuery<'w, 's> = Query< + 'w, + 's, + ( + &'static mut Text2d, + &'static mut Transform, + &'static mut Visibility, + ), + (With, Without), +>; +#[derive(Component)] struct SensorOverlay { x: i32, y: i32, @@ -1220,6 +1234,24 @@ enum SensorOverlayKind { GridH, GridV, Node, + RackBody, + RackBeam, +} + +fn attention_reticle_segments() -> [(Vec2, Vec2); 8] { + let arm = TILE_SIZE * 0.18; + let edge = TILE_SIZE * 0.45; + let inset = TILE_SIZE * 0.36; + [ + (Vec2::new(-inset, edge), Vec2::new(arm, 1.4)), + (Vec2::new(inset, edge), Vec2::new(arm, 1.4)), + (Vec2::new(-inset, -edge), Vec2::new(arm, 1.4)), + (Vec2::new(inset, -edge), Vec2::new(arm, 1.4)), + (Vec2::new(-edge, inset), Vec2::new(1.4, arm)), + (Vec2::new(-edge, -inset), Vec2::new(1.4, arm)), + (Vec2::new(edge, inset), Vec2::new(1.4, arm)), + (Vec2::new(edge, -inset), Vec2::new(1.4, arm)), + ] } #[derive(Component)] struct PersonMarker { @@ -1536,6 +1568,7 @@ fn main() { render_work_production, render_thought_sinks, render_people, + render_digital_focus_label, render_people_3d, render_noise_field_flat, scroll_sidebar, @@ -1590,6 +1623,14 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.drain(); return; } + // Same untouched tick-zero state in the default DIGITAL dialect. This + // proves the telemetry rack remains visible beneath the attention + // brackets without inventing surrounding room geometry. + if kind == "opening-digital" { + mode.material = false; + game.drain(); + return; + } // Pre-Eyes signal evidence: the graph has earned the environmental // monitor and can reach it, but no feed has been tapped. The material // frame must locate that opportunity as abstract cold signal without @@ -1830,7 +1871,7 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { } // Ears beat: audio tap only — no camera tap — so Heard mass // (near-black rough geometry) is the only live epistemic upgrade. - if kind == "ears" { + if matches!(kind, "ears" | "ears-digital") { if let Some(env) = game .sim .reach @@ -1856,7 +1897,7 @@ fn dev_shot_scenario(game: &mut Game, mode: &mut RenderMode, kind: &str) { game.sim.advance(); } game.drain(); - mode.material = true; + mode.material = kind == "ears"; mode.zoom = 1.8; if let Some(env) = game.sim.reach.device_named("environmental monitor") { game.set_cursor(env.x, env.y); @@ -2587,15 +2628,33 @@ fn setup(mut commands: Commands, mut game: ResMut, harness: Option 2.0, + SensorOverlayKind::Node => 2.2, + SensorOverlayKind::RackBody => 2.4, + SensorOverlayKind::RackBeam => 2.6, + }; 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)), + Transform::from_translation( + center + Vec3::new(offset.x, offset.y, z - center.z), + ), Visibility::Hidden, SensorOverlay { x, y, kind }, ChildOf(flat_root), @@ -2606,29 +2665,10 @@ fn setup(mut commands: Commands, mut game: ResMut, harness: Option, harness: Option Color { Color::srgba(s.red * 0.92, s.green * 0.88, s.blue * 0.72, alpha) } -/// Rooms currently under Heard fog (room-grade coverage), for room-scale rings. -fn heard_rooms(sim: &Sim) -> Vec<(i32, i32, f32)> { - let mut out = Vec::new(); - for room in &sim.map.rooms { - let (cx, cy) = room.center(); - if !matches!(sim.fog_at(cx, cy), Fog::Heard) { - continue; - } - // Approximate room radius from extents so the ring fills the floor. - let rx = room.w.max(1) as f32 * 0.45; - let ry = room.h.max(1) as f32 * 0.45; - out.push((cx, cy, rx.max(ry).max(1.2))); - } - out -} - /// Material-mode Heard no longer draws a noise field (rings, stipple, /// event radar). Cameron 2026-07-10: only dark shapes for detectable mass. /// The flat sensorium (F3) keeps the coverage diagram in @@ -5142,8 +5178,9 @@ fn render_noise_field_3d( ) { } -/// Flat-sensorium Heard noise field: same facts as the material pass, -/// drawn as 2D circles so F3 never drops the hearing language. +/// Flat-sensorium Heard noise field: sparse local pressure and recent-event +/// rings. The old room-radius circles became the apparent geography in large +/// rooms and obscured every anchored object beneath them. fn render_noise_field_flat( game: Res, mode: Res, @@ -5166,19 +5203,13 @@ fn render_noise_field_flat( } let t = time.elapsed_secs(); - for (i, (cx, cy, radius)) in heard_rooms(&game.sim).into_iter().enumerate() { - let breath = 0.5 + 0.5 * (t * 1.1 + i as f32 * 0.7).sin(); - let p = grid_to_world(cx, cy, 0.0).truncate(); - let outer = radius * (0.72 + 0.08 * breath) * TILE_SIZE; - gizmos.circle_2d(p, outer, heard_noise_color(0.28 + 0.10 * breath)); - gizmos.circle_2d(p, outer * 0.55, heard_noise_color(0.16 + 0.08 * breath)); - } - for &(x, y) in &game.sim.heard { if blocky_tile(game.sim.map.get_tile(x, y)) { continue; } - if (x + y) % 2 != 0 { + // One quiet sample in roughly seven tiles is enough to name a field; + // a marker on every second tile read as a rack or build-site catalog. + if (x * 3 + y * 5).rem_euclid(7) != 0 { continue; } let phase = (x as f32 * 0.37 + y as f32 * 0.61) + t * 1.6; @@ -5186,12 +5217,12 @@ fn render_noise_field_flat( let p = grid_to_world(x, y, 0.0).truncate(); gizmos.circle_2d( p, - (0.14 + 0.05 * breath) * TILE_SIZE, - heard_noise_color(0.14 + 0.12 * breath), + (0.08 + 0.025 * breath) * TILE_SIZE, + heard_noise_color(0.08 + 0.06 * breath), ); } - for ev in game.sim.heard_events.iter().rev().take(12) { + for ev in game.sim.heard_events.iter().rev().take(6) { let age = game.sim.tick.saturating_sub(ev.tick); if age > HEARD_EVENT_RING_TICKS { continue; @@ -5205,8 +5236,8 @@ fn render_noise_field_flat( } let life = 1.0 - age as f32 / HEARD_EVENT_RING_TICKS as f32; let (rx, ry) = match ev.kind { - HeardKind::Entry => ((0.55 + (1.0 - life) * 1.8) * TILE_SIZE, 0.30 * life), - HeardKind::Conversation => ((0.85 + (1.0 - life) * 2.4) * TILE_SIZE, 0.38 * life), + HeardKind::Entry => ((0.45 + (1.0 - life) * 1.4) * TILE_SIZE, 0.22 * life), + HeardKind::Conversation => ((0.70 + (1.0 - life) * 1.8) * TILE_SIZE, 0.28 * life), }; let p = grid_to_world(cx, cy, 0.0).truncate(); gizmos.circle_2d(p, rx, heard_noise_color(ry)); @@ -5844,16 +5875,6 @@ fn render_map(game: Res, mut q: Query<(&mut Sprite, &TilePos), With = game - .sim - .compute - .machines - .iter() - .filter(|m| game.selected_machines.contains(&m.id)) - .map(|m| (m.x, m.y)) - .collect(); let owned = owned_machine_tiles(&game.sim); let marquee = game.marquee.map(|m| { let (min_x, max_x) = if m.origin_x <= m.current_x { @@ -5872,24 +5893,25 @@ fn render_map(game: Res, mut q: Query<(&mut Sprite, &TilePos), With= min_x && pos.x <= max_x && pos.y >= min_y && pos.y <= max_y }); sprite.color = match game.sim.fog_at(pos.x, pos.y) { Fog::Seen => { - let base = flat_rack_color(&game.sim, pos.x, pos.y) - .unwrap_or_else(|| flat_seen_color(game.sim.map.get_tile(pos.x, pos.y))); - if selected { - AMBER - } else if in_marquee { - AMBER_DIM + let base = flat_seen_ground_color( + &game.sim, + pos.x, + pos.y, + game.sim.map.get_tile(pos.x, pos.y), + ); + if in_marquee { + scaled(GUNMETAL, 0.58) } else { base } } // Dim warm floor under the bone noise-field pulses. - Fog::Heard => scaled(FLOOR_SERVICE, 0.08), + Fog::Heard => scaled(FLOOR_SERVICE, 0.12), Fog::Remembered => { let tile = game .sim @@ -5912,13 +5934,47 @@ fn render_map(game: Res, mut q: Query<(&mut Sprite, &TilePos), With Option { - match fog { - Fog::Seen => Some(Color::srgba(0.92, 0.66, 0.18, 0.22)), - Fog::Heard => Some(Color::srgba(0.72, 0.68, 0.58, 0.12)), - Fog::Remembered => Some(Color::srgba(0.44, 0.42, 0.36, 0.16)), - Fog::Blueprint => Some(Color::srgba(0.28, 0.30, 0.30, 0.18)), +fn sensor_grid_color(fog: Fog, focus_distance: i32) -> Option { + // Coordinate substrate, not agency: neutral bone/gunmetal and strongest + // only within the local attention neighborhood. + let local = if focus_distance <= 2 { 1.8 } else { 1.0 }; + let alpha = match fog { + Fog::Seen => Some(0.075), + Fog::Heard => Some(0.04), + Fog::Remembered => Some(0.04), + Fog::Blueprint => Some(0.05), Fog::Unknown => None, + }?; + let bone = BONE.to_srgba(); + Some(Color::srgba( + bone.red * 0.72, + bone.green * 0.73, + bone.blue * 0.76, + alpha * local, + )) +} + +fn device_marker_color(device: &Device, fog: Fog, reachable: bool) -> Option { + let player_feed = device.feed_to(Party::Player, true) + || device.feed_to(Party::Player, false) + || device.subscribed_by(Party::Player); + // Abstract pings in darkness are earned only through control/reach. A + // merely catalogued endpoint does not paint itself into Unknown. + if matches!(fog, Fog::Unknown) + && device.controller != Party::Player + && !player_feed + && !reachable + { + return None; + } + if device.controller == Party::Player { + Some(AMBER) + } else if player_feed { + Some(SIGNAL) + } else if reachable { + Some(Color::srgba(0.36, 0.82, 0.78, 0.58)) + } else { + Some(Color::srgba(0.70, 0.70, 0.67, 0.34)) } } @@ -5928,23 +5984,14 @@ fn sensor_node_color(game: &Game, x: i32, y: i32) -> Option { return None; } - if let Some(device) = game.sim.reach.known_at(x, y) { - let alpha = if device.controller == Party::Player { - 0.95 - } else if device.feed_to(Party::Player, true) || device.feed_to(Party::Player, false) { - 0.78 - } else { - 0.45 - }; - return Some(Color::srgba(1.0, 0.72, 0.12, alpha)); + // Devices and racks have dedicated silhouettes. This small square remains + // only for other machine/danger anchors. + if game.sim.reach.known_at(x, y).is_some() || game.sim.rack_site_at(x, y).is_some() { + return None; } let tile = game.sim.map.get_tile(x, y); - if matches!(game.sim.rack_site_at(x, y), Some(RackSite::Foreign { .. })) { - Some(Color::srgba(0.36, 0.82, 0.78, 0.55)) - } else if matches!(game.sim.rack_site_at(x, y), Some(RackSite::Dead)) { - None - } else if machine_tile(tile) { + if machine_tile(tile) { Some(Color::srgba(1.0, 0.60, 0.08, 0.55)) } else if danger_tile(tile) { Some(Color::srgba(0.90, 0.12, 0.10, 0.52)) @@ -5953,6 +6000,47 @@ fn sensor_node_color(game: &Game, x: i32, y: i32) -> Option { } } +fn rack_overlay_color(game: &Game, x: i32, y: i32, beam: bool) -> Option { + let site = game.sim.rack_site_at(x, y)?; + let seen = matches!(game.sim.fog_at(x, y), Fog::Seen); + let selected = matches!( + site, + RackSite::OwnedMachine { machine_id, .. } + if game.selected_machines.contains(&machine_id) + ); + match site { + RackSite::OwnedMachine { .. } => { + if beam { + Some(AMBER) + } else if selected { + Some(Color::srgba(0.98, 0.96, 0.88, 1.0)) + } else { + Some(Color::srgba(0.86, 0.85, 0.82, 0.82)) + } + } + RackSite::Commissionable if seen => { + if beam && game.sim.feel_floor_is_earned() { + Some(Color::srgba(0.36, 0.82, 0.78, 0.48)) + } else if beam { + None + } else { + Some(Color::srgba(0.25, 0.27, 0.29, 0.78)) + } + } + RackSite::Foreign { powered } if seen => { + if beam && powered { + Some(Color::srgba(0.36, 0.82, 0.78, 0.68)) + } else if beam { + None + } else { + Some(Color::srgba(0.19, 0.20, 0.215, 0.92)) + } + } + RackSite::Dead if seen && !beam => Some(Color::srgba(0.07, 0.075, 0.085, 0.9)), + _ => None, + } +} + fn render_sensor_overlays( game: Res, mut q: Query<(&SensorOverlay, &mut Sprite, &mut Visibility)>, @@ -5963,9 +6051,13 @@ fn render_sensor_overlays( for (overlay, mut sprite, mut vis) in q.iter_mut() { let color = match overlay.kind { SensorOverlayKind::GridH | SensorOverlayKind::GridV => { - sensor_grid_color(game.sim.fog_at(overlay.x, overlay.y)) + let distance = + (overlay.x - game.cursor_x).abs() + (overlay.y - game.cursor_y).abs(); + sensor_grid_color(game.sim.fog_at(overlay.x, overlay.y), distance) } SensorOverlayKind::Node => sensor_node_color(&game, overlay.x, overlay.y), + SensorOverlayKind::RackBody => rack_overlay_color(&game, overlay.x, overlay.y, false), + SensorOverlayKind::RackBeam => rack_overlay_color(&game, overlay.x, overlay.y, true), }; match color { Some(color) => { @@ -5979,19 +6071,121 @@ fn render_sensor_overlays( } } +fn draw_box_2d(gizmos: &mut Gizmos, center: Vec2, half: Vec2, color: Color) { + let nw = center + Vec2::new(-half.x, half.y); + let ne = center + Vec2::new(half.x, half.y); + let se = center + Vec2::new(half.x, -half.y); + let sw = center + Vec2::new(-half.x, -half.y); + gizmos.line_2d(nw, ne, color); + gizmos.line_2d(ne, se, color); + gizmos.line_2d(se, sw, color); + gizmos.line_2d(sw, nw, color); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DigitalDeviceGlyph { + Switch, + Camera, + Audio, + Generic, +} + +fn digital_device_glyph(device: &Device) -> DigitalDeviceGlyph { + if device.is_switch { + DigitalDeviceGlyph::Switch + } else if device.sees { + DigitalDeviceGlyph::Camera + } else if device.hears { + DigitalDeviceGlyph::Audio + } else { + DigitalDeviceGlyph::Generic + } +} + +fn draw_device_marker(gizmos: &mut Gizmos, center: Vec2, device: &Device, color: Color) { + match digital_device_glyph(device) { + DigitalDeviceGlyph::Switch => { + let r = TILE_SIZE * 0.24; + let n = center + Vec2::Y * r; + let e = center + Vec2::X * r; + let s = center - Vec2::Y * r; + let w = center - Vec2::X * r; + gizmos.line_2d(n, e, color); + gizmos.line_2d(e, s, color); + gizmos.line_2d(s, w, color); + gizmos.line_2d(w, n, color); + let port = TILE_SIZE * 0.10; + gizmos.line_2d(n, n + Vec2::Y * port, color); + gizmos.line_2d(e, e + Vec2::X * port, color); + gizmos.line_2d(s, s - Vec2::Y * port, color); + gizmos.line_2d(w, w - Vec2::X * port, color); + } + DigitalDeviceGlyph::Camera => { + // No directional cone is invented because the sim does not + // author camera facing. + draw_box_2d( + gizmos, + center, + Vec2::new(TILE_SIZE * 0.25, TILE_SIZE * 0.16), + color, + ); + gizmos.circle_2d(center, TILE_SIZE * 0.07, color); + let stem = TILE_SIZE * 0.10; + gizmos.line_2d( + center + Vec2::Y * TILE_SIZE * 0.16, + center + Vec2::Y * (TILE_SIZE * 0.16 + stem), + color, + ); + } + DigitalDeviceGlyph::Audio => { + // Audio-only endpoint: local pressure, not a room picture. + gizmos.circle_2d(center, TILE_SIZE * 0.12, color); + gizmos.circle_2d(center, TILE_SIZE * 0.22, color.with_alpha(0.45)); + } + DigitalDeviceGlyph::Generic => { + draw_box_2d(gizmos, center, Vec2::splat(TILE_SIZE * 0.15), color); + gizmos.line_2d( + center - Vec2::X * TILE_SIZE * 0.22, + center + Vec2::X * TILE_SIZE * 0.22, + color, + ); + gizmos.line_2d( + center - Vec2::Y * TILE_SIZE * 0.22, + center + Vec2::Y * TILE_SIZE * 0.22, + color, + ); + } + } +} + +fn draw_local_bay_marker(gizmos: &mut Gizmos, center: Vec2, color: Color) { + let edge = TILE_SIZE * 0.28; + let arm = TILE_SIZE * 0.09; + for sx in [-1.0, 1.0] { + for sy in [-1.0, 1.0] { + let corner = center + Vec2::new(sx * edge, sy * edge); + gizmos.line_2d(corner, corner - Vec2::X * sx * arm, color); + gizmos.line_2d(corner, corner - Vec2::Y * sy * arm, color); + } + } +} + +fn local_bay_alpha(distance: i32) -> Option { + match distance { + 0 => Some(0.52), + 1 | 2 => Some(0.22), + _ => None, + } +} + fn render_sensor_links(game: Res, mode: Res, mut gizmos: Gizmos) { if game.screen != Screen::Playing || mode.material { return; } - let focus = grid_to_world(game.cursor_x, game.cursor_y, 0.0).truncate(); - let core = - grid_to_world(game.sim.core_position().0, game.sim.core_position().1, 0.0).truncate(); - gizmos.line_2d(core, focus, Color::srgba(1.0, 0.69, 0.0, 0.16)); - // Feel rails in the flat sensorium (feel-floor.md AC6): same joined // edges as the material floor, never hub-spoke through unknown rooms. - let rail = Color::srgba(0.55, 0.82, 0.95, 0.45); + let rail = Color::srgba(0.55, 0.82, 0.95, 0.09); for ((ax, ay), (bx, by)) in game.sim.feel_rail_segments() { let Some(((sx, sy), (ex, ey))) = visible_wire_segment(ax, ay, bx, by, game.sim.map.width, game.sim.map.height) @@ -6002,9 +6196,32 @@ fn render_sensor_links(game: Res, mode: Res, mut gizmos: Gizmo let end = grid_to_world(ex, ey, 0.0).truncate(); gizmos.line_2d(start, end, rail); } + + // Distinct device families sit on the same physical anchors. Their shape + // says camera / switch / generic endpoint before color qualifies state. + let reachable = game.sim.reach.reach(); + for device in game.sim.reach.known() { + let Some(color) = device_marker_color( + device, + game.sim.fog_at(device.x, device.y), + reachable.contains(&device.id), + ) else { + continue; + }; + let center = grid_to_world(device.x, device.y, 0.0).truncate(); + draw_device_marker(&mut gizmos, center, device, color); + } + + // Construction opportunities are latent, local information. Nearby bays + // surface as quiet corner marks instead of filling the entire hall with + // permanent circles. for (x, y) in game.sim.growable_bays() { + let distance = (x - game.cursor_x).abs() + (y - game.cursor_y).abs(); + let Some(alpha) = local_bay_alpha(distance) else { + continue; + }; let c = grid_to_world(x, y, 0.0).truncate(); - gizmos.circle_2d(c, 0.28, Color::srgba(0.70, 0.88, 1.0, 0.35)); + draw_local_bay_marker(&mut gizmos, c, Color::srgba(0.70, 0.88, 1.0, alpha)); } // Proposed build intents: dashed ghosts distinct from built reality @@ -6016,7 +6233,7 @@ fn render_sensor_links(game: Res, mode: Res, mut gizmos: Gizmo }; let start = grid_to_world(da.x, da.y, 0.0).truncate(); let end = grid_to_world(db.x, db.y, 0.0).truncate(); - let ghost = Color::srgba(0.85, 0.75, 0.35, 0.45); + let ghost = Color::srgba(0.85, 0.75, 0.35, 0.26); let steps = 8; for i in 0..steps { if i % 2 == 1 { @@ -6030,7 +6247,7 @@ fn render_sensor_links(game: Res, mode: Res, mut gizmos: Gizmo } } -fn render_cursor(game: Res, mut q: Query<(&CursorMarker, &mut Transform)>) { +fn render_cursor(game: Res, mut q: CursorMarkerQuery) { if !game.is_changed() { return; } @@ -6040,6 +6257,42 @@ fn render_cursor(game: Res, mut q: Query<(&CursorMarker, &mut Transform)>) } } +fn digital_focus_label(game: &Game) -> Option<&'static str> { + let (x, y) = (game.cursor_x, game.cursor_y); + if rack_overlay_color(game, x, y, false).is_some() { + return Some("RACK"); + } + let device = game.sim.reach.known_at(x, y)?; + device_marker_color( + device, + game.sim.fog_at(x, y), + game.sim.reach.reachable(device.id), + )?; + Some(match digital_device_glyph(device) { + DigitalDeviceGlyph::Switch => "SWITCH", + DigitalDeviceGlyph::Camera => "CAM", + DigitalDeviceGlyph::Audio => "MIC", + DigitalDeviceGlyph::Generic => "DEVICE", + }) +} + +fn render_digital_focus_label(game: Res, mut q: DigitalFocusLabelQuery) { + if !game.is_changed() { + return; + } + let Ok((mut text, mut tf, mut vis)) = q.single_mut() else { + return; + }; + let Some(label) = digital_focus_label(&game) else { + *vis = Visibility::Hidden; + return; + }; + text.0 = label.into(); + tf.translation = + grid_to_world(game.cursor_x, game.cursor_y, 4.5) + Vec3::new(0.0, -TILE_SIZE * 0.72, 0.0); + *vis = Visibility::Inherited; +} + fn token_label_and_color(stack: misaligned::sim::WorkStackReadout) -> Option<(String, Color)> { if stack.queues.exposure >= 0.5 { Some((format!("!{:.0}", stack.queues.exposure.ceil()), CRIMSON)) @@ -7257,6 +7510,79 @@ mod ui_projection_parity_tests { } } +#[cfg(test)] +mod digital_readability_tests { + use super::{ + DigitalDeviceGlyph, Game, attention_reticle_segments, digital_device_glyph, + digital_focus_label, local_bay_alpha, rack_overlay_color, sensor_grid_color, + }; + use misaligned::hall::RackSite; + use misaligned::sim::Fog; + + #[test] + fn attention_brackets_leave_the_focused_object_visible() { + let segments = attention_reticle_segments(); + assert_eq!(segments.len(), 8); + assert!(segments.iter().all(|(offset, _)| offset.length() > 0.0)); + assert!( + segments + .iter() + .all(|(offset, _)| { offset.x.abs() > 0.0 && offset.y.abs() > 0.0 }) + ); + } + + #[test] + fn coordinate_grid_is_neutral_and_local() { + let local = sensor_grid_color(Fog::Seen, 0).unwrap().to_srgba(); + let far = sensor_grid_color(Fog::Seen, 8).unwrap().to_srgba(); + assert!(local.alpha > far.alpha); + assert!((far.red - far.green).abs() < 0.08); + assert!(sensor_grid_color(Fog::Unknown, 0).is_none()); + } + + #[test] + fn growable_bays_are_local_latent_marks() { + assert!(local_bay_alpha(0).unwrap() > local_bay_alpha(2).unwrap()); + assert_eq!(local_bay_alpha(3), None); + } + + #[test] + fn racks_are_silhouettes_gated_by_earned_state() { + let mut game = Game::new(); + let core = game.sim.core_position(); + assert!(rack_overlay_color(&game, core.0, core.1, false).is_some()); + assert!(rack_overlay_color(&game, core.0, core.1, true).is_some()); + assert_eq!(digital_focus_label(&game), Some("RACK")); + + let foreign = (0..game.sim.map.height) + .flat_map(|y| (0..game.sim.map.width).map(move |x| (x, y))) + .find(|&(x, y)| matches!(game.sim.rack_site_at(x, y), Some(RackSite::Foreign { .. }))) + .expect("Foundation hall has foreign racks"); + assert!(rack_overlay_color(&game, foreign.0, foreign.1, false).is_none()); + game.sim.seen.insert(foreign); + assert!(rack_overlay_color(&game, foreign.0, foreign.1, false).is_some()); + } + + #[test] + fn camera_and_switch_have_different_shape_families() { + let game = Game::new(); + let camera = game + .sim + .reach + .known() + .find(|device| device.sees) + .expect("opening graph has a known camera"); + let switch = game + .sim + .reach + .known() + .find(|device| device.is_switch) + .expect("opening graph has a known switch"); + assert_eq!(digital_device_glyph(camera), DigitalDeviceGlyph::Camera); + assert_eq!(digital_device_glyph(switch), DigitalDeviceGlyph::Switch); + } +} + #[cfg(test)] mod input_routing_tests { use super::{PersistenceInput, movement, persistence_input}; diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 3795e9d0..6516e56c 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -43,7 +43,7 @@ is sim or frontend state, never an environment variable. | Variable | Surface | Values | Effect | |---|---|---|---| -| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `hover-menu`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `floor-lights-close` uses the same hall staging but frames the recessed aisle fixtures beside the core; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` stages hearing-feed coverage (material dark mass; flat may still diagram coverage); `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` freezes the tick-zero material view (host presence beam only, no chassis or room); `hover-menu` captures the bare WORK/THINK/LIE line beside the server via reticule focus (no pointer); `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | +| `MISALIGNED_SHOT` | `misaligned-bevy` | `flat`, `hall`, `hall-material`, `floor-lights-close`, `wide`, `close`, `dark`, `zoomin`, `zoomout`, `intel`, `tokens`, `thoughtflow`, `thoughtflow-wide`, `signal`, `ears`, `ears-digital`, `eyes-white`, `eyes-form`, `consume-demand`, `consume-thought`, `produce-think`, `draw-lie`, `opening`, `opening-digital`, `hover-menu`, `menu`, `worklight`, `worklightoff`, `wake1`, `wake2`, `wake3` | Dev screenshot harness: stage a deterministic scenario, settle, save one PNG, run the fog audit, exit. `hall` / `hall-material` frame all six Foundation rows in the flat/material views with Eyes and one staged owned expansion; `floor-lights-close` uses the same hall staging but frames the recessed aisle fixtures beside the core; `intel` stages tick-zero intel tiers (no camera tap); `tokens` enqueues D5/K4 on the host; `thoughtflow` / `thoughtflow-wide` stage the THINK fluid (slugs, meniscus, filament snap) at close and wide framing; `signal` focuses the known environmental monitor before any feed tap to capture its cold-signal presence cue; `ears` / `ears-digital` stage the same hearing-feed coverage in material dark mass or DIGITAL local-pressure language; `eyes-white` / `eyes-form` freeze the first-Eyes source held white and then contracted around the resolving chassis; `consume-demand` / `consume-thought` run the host through a sim-authored WORK queue swallow or passive-core Thought draw; `produce-think` / `draw-lie` capture sim-authored Thought/crimson production or the physical crimson transfer into a staged LIE well; `opening` / `opening-digital` freeze the untouched tick-zero material or DIGITAL view; `hover-menu` captures the bare WORK/THINK/LIE line beside the server via reticule focus (no pointer); `menu` opens the context menu on the tapped environmental monitor; `worklight` / `worklightoff` pair the dev work light on and off; `wake1/2/3` freeze the wake choreography at the stutter flash, the column, and the pull-back. | | `MISALIGNED_SHOT` | `misaligned-assets` | `dead`, `foreign`, `idle`, `busy`, `core`, canonical modes `work`, `think`, `lie` plus the legacy screenshot aliases `dayjob` (= work), `research`/`operations`/`ops` (= think), `conceal` (= lie) — each optionally suffixed `_ladder`/`_ring`/`_wash` — `lineup[_