diff --git a/crates/misaligned-bevy/src/main.rs b/crates/misaligned-bevy/src/main.rs index c8bf1b12..67d5bfe8 100644 --- a/crates/misaligned-bevy/src/main.rs +++ b/crates/misaligned-bevy/src/main.rs @@ -113,12 +113,13 @@ use rail_ui::{ use shot_harness::{dev_shot_scenario, fog_audit_3d, shot_harness_system}; use thought_effects::{GameThoughtEffect, GameThoughtEffects, sync_game_thought_effects}; use world_annotations::{ - CursorMarker, DigitalFocusLabel, HoverVerbBar, HoverVerbFamily, HoverVerbGroup, - HoverVerbReceipt, HoverVerbReceiptText, HoverVerbWord, OperatorCue, PersonEvidenceMarker, - PersonMarker, PersonWorkMarker, TokenMarker, annotation_redirect, hover_verb_bar_anchor, - render_cursor, render_digital_focus_label, render_hover_identity, render_hover_verb_bar, - render_operator_cue, render_people, render_people_presence, render_person_evidence_markers, - render_person_work_markers, render_tokens, setup_hover_identity, + CursorMarker, DIGITAL_FOCUS_LABEL_WIDTH, DigitalFocusLabel, HoverVerbBar, HoverVerbFamily, + HoverVerbGroup, HoverVerbReceipt, HoverVerbReceiptText, HoverVerbWord, OPERATOR_CUE_WIDTH, + OperatorCue, PersonEvidenceMarker, PersonMarker, PersonWorkMarker, WORLD_ANNOTATION_FONT_PX, + WORLD_ANNOTATION_HEIGHT, annotation_redirect, hover_verb_bar_anchor, render_cursor, + render_digital_focus_label, render_hover_identity, render_hover_verb_bar, render_operator_cue, + render_people, render_people_presence, render_person_evidence_markers, + render_person_work_markers, render_tokens, setup_hover_identity, sync_token_markers, }; const TILE_SIZE: f32 = 16.0; @@ -1983,7 +1984,7 @@ fn main() { render_work_routes, render_audio_events_3d, render_cursor, - render_tokens, + (sync_token_markers, ApplyDeferred, render_tokens).chain(), ), ( sync_data_hall_infrastructure, @@ -2205,17 +2206,22 @@ fn setup( )); } commands.spawn(( - Text2d::new(""), + Text::new(""), TextFont { - font_size: 8.0, + font_size: WORLD_ANNOTATION_FONT_PX, ..default() }, TextColor(BONE.with_alpha(0.76)), - Transform::from_translation(center + Vec3::new(0.0, -TILE_SIZE * 0.72, 0.5)), - bevy::sprite::Anchor::CENTER, + TextLayout::new_with_justify(Justify::Center), + Node { + position_type: PositionType::Absolute, + width: Val::Px(DIGITAL_FOCUS_LABEL_WIDTH), + height: Val::Px(WORLD_ANNOTATION_HEIGHT), + ..default() + }, Visibility::Hidden, + GlobalZIndex(29), DigitalFocusLabel, - ChildOf(flat_root), )); // Secondary identity glyph per person (initial once identified, '?' while @@ -2261,26 +2267,9 @@ fn setup( )); } - // Visible token stacks (machine-work.md) are 2D overlay labels rather - // than frontend counters: render_tokens reads Sim/WorkGrid each frame. - // They are intentionally not children of Flat2dRoot so they remain - // visible over the material (REAL) render as well as the sensorium. - for y in 0..h { - for x in 0..w { - commands.spawn(( - Text2d::new(""), - TextFont { - font_size: 11.0, - ..default() - }, - TextColor(BONE), - Transform::from_translation(grid_to_world(x, y, 5.5)), - bevy::sprite::Anchor::CENTER, - Visibility::Hidden, - TokenMarker { x, y }, - )); - } - } + // Token annotations are synchronized to live machine ids at runtime. + // Keeping them in screen-space UI avoids camera-scaled glyphs and avoids + // preallocating one hidden text entity for every map tile. game.drain(); } @@ -5022,7 +5011,7 @@ fn setup_ui(mut commands: Commands) { commands.spawn(( Text::new(""), TextFont { - font_size: 12.0, + font_size: WORLD_ANNOTATION_FONT_PX, ..default() }, TextColor(AMBER), @@ -5030,7 +5019,7 @@ fn setup_ui(mut commands: Commands) { position_type: PositionType::Absolute, left: Val::Px(0.0), top: Val::Px(0.0), - width: Val::Px(132.0), + width: Val::Px(OPERATOR_CUE_WIDTH), padding: UiRect::axes(Val::Px(7.0), Val::Px(3.0)), border: UiRect::axes(Val::Px(2.0), Val::Px(0.0)), ..default() diff --git a/crates/misaligned-bevy/src/world_annotations.rs b/crates/misaligned-bevy/src/world_annotations.rs index dc328615..5270ca05 100644 --- a/crates/misaligned-bevy/src/world_annotations.rs +++ b/crates/misaligned-bevy/src/world_annotations.rs @@ -12,9 +12,9 @@ type DigitalFocusLabelQuery<'w, 's> = Query< 'w, 's, ( - &'static mut Text2d, - &'static mut Transform, - &'static mut bevy::sprite::Anchor, + &'static mut Text, + &'static mut TextLayout, + &'static mut Node, &'static mut Visibility, ), (With, Without), @@ -34,8 +34,56 @@ pub(super) struct PersonEvidenceMarker { } #[derive(Component)] pub(super) struct TokenMarker { - pub(super) x: i32, - pub(super) y: i32, + pub(super) machine_id: u32, +} + +/// Concise text attached to the world is still interface text. Keep every +/// member of that family in Bevy UI at one logical size so the world camera +/// cannot enlarge one label, shrink another, or resample glyph edges between +/// zoom levels. +pub(super) const WORLD_ANNOTATION_FONT_PX: f32 = 12.0; +pub(super) const WORLD_ANNOTATION_HEIGHT: f32 = 16.0; +pub(super) const DIGITAL_FOCUS_LABEL_WIDTH: f32 = 180.0; +pub(super) const OPERATOR_CUE_WIDTH: f32 = 168.0; +const TOKEN_CHIP_WIDTH: f32 = 64.0; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum AnnotationAnchor { + Center, + CenterRight, + BottomLeft, +} + +/// UI coordinates are logical pixels. Snap the attachment point to a backing +/// pixel before layout so Retina rendering never lands glyphs between samples. +fn snap_to_backing_pixel(value: f32, scale_factor: f32) -> f32 { + let scale = scale_factor.max(1.0); + (value * scale).round() / scale +} + +fn annotation_top_left( + point: Vec2, + size: Vec2, + anchor: AnnotationAnchor, + scale_factor: f32, +) -> Vec2 { + let top_left = match anchor { + AnnotationAnchor::Center => point - size * 0.5, + AnnotationAnchor::CenterRight => point - Vec2::new(size.x, size.y * 0.5), + AnnotationAnchor::BottomLeft => point - Vec2::new(0.0, size.y), + }; + Vec2::new( + snap_to_backing_pixel(top_left.x, scale_factor), + snap_to_backing_pixel(top_left.y, scale_factor), + ) +} + +fn flat_annotation_point( + camera_q: &Query<(&Camera, &GlobalTransform), With>, + world: Vec3, +) -> Option { + let (camera, camera_tf) = camera_q.single().ok()?; + camera.world_to_viewport(camera_tf, world).ok() } /// Root for the menu-closed frequent action surface: machine selection owns a /// stable `WORK / THINK / LIE` UI strip; an exact committed local action may @@ -230,27 +278,31 @@ pub(super) fn digital_focus_label(game: &Game) -> Option { )) } -fn digital_focus_label_layout(device_label: bool) -> (bevy::sprite::Anchor, Vec2) { +fn digital_focus_label_layout(device_label: bool) -> (AnnotationAnchor, Vec2) { if device_label { ( - bevy::sprite::Anchor::CENTER_RIGHT, + AnnotationAnchor::CenterRight, Vec2::new(-TILE_SIZE * 0.45, -TILE_SIZE * 0.72), ) } else { - ( - bevy::sprite::Anchor::CENTER, - Vec2::new(0.0, -TILE_SIZE * 0.95), - ) + (AnnotationAnchor::Center, Vec2::new(0.0, -TILE_SIZE * 0.95)) } } -pub(super) fn render_digital_focus_label(game: Res, mut q: DigitalFocusLabelQuery) { - if !game.is_changed() { - return; - } - let Ok((mut text, mut tf, mut anchor, mut vis)) = q.single_mut() else { +pub(super) fn render_digital_focus_label( + game: Res, + mode: Res, + windows: Query<&Window, With>, + camera_q: Query<(&Camera, &GlobalTransform), With>, + mut q: DigitalFocusLabelQuery, +) { + let Ok((mut text, mut layout, mut node, mut vis)) = q.single_mut() else { return; }; + if mode.material || world_overlays_suppressed(&game) { + *vis = Visibility::Hidden; + return; + } let Some(label) = digital_focus_label(&game) else { *vis = Visibility::Hidden; return; @@ -258,9 +310,31 @@ pub(super) fn render_digital_focus_label(game: Res, mut q: DigitalFocusLab text.0 = label; let device_label = rack_overlay_color(&game, game.cursor_x, game.cursor_y, false).is_none(); let (label_anchor, label_offset) = digital_focus_label_layout(device_label); - *anchor = label_anchor; - tf.translation = grid_to_world(game.cursor_x, game.cursor_y, 4.5) - + Vec3::new(label_offset.x, label_offset.y, 0.0); + layout.justify = if device_label { + Justify::Right + } else { + Justify::Center + }; + let Some(point) = flat_annotation_point( + &camera_q, + grid_to_world(game.cursor_x, game.cursor_y, 4.5) + + Vec3::new(label_offset.x, label_offset.y, 0.0), + ) else { + *vis = Visibility::Hidden; + return; + }; + let Ok(window) = windows.single() else { + *vis = Visibility::Hidden; + return; + }; + let top_left = annotation_top_left( + point, + Vec2::new(DIGITAL_FOCUS_LABEL_WIDTH, WORLD_ANNOTATION_HEIGHT), + label_anchor, + window.scale_factor(), + ); + node.left = Val::Px(top_left.x); + node.top = Val::Px(top_left.y); *vis = Visibility::Inherited; } @@ -282,34 +356,25 @@ fn token_label_and_color(stack: misaligned::sim::WorkStackReadout) -> Option<(St #[derive(Clone, Copy, Debug, PartialEq)] struct TokenMarkerLayout { - anchor: bevy::sprite::Anchor, + anchor: AnnotationAnchor, offset: Vec2, - font_size: f32, } /// The one chip slot: world offset from the machine's tile center to the /// bottom-left corner of every token label. const TOKEN_CHIP_SLOT: Vec2 = Vec2::new(TILE_SIZE * 0.48, TILE_SIZE * 0.46); -/// Ordinary token tier (the D/T counts). -const TOKEN_CHIP_FONT_PX: f32 = 11.0; -/// The quieter expanded-word tier (INFO). -const TOKEN_CHIP_WORD_FONT_PX: f32 = 8.0; -fn token_marker_layout(label: &str) -> TokenMarkerLayout { +fn token_marker_layout(_label: &str) -> TokenMarkerLayout { // Token grammar (digital-read.md, amended 2026-08-01): a token label is // a note about the machine, never chassis typography. Every current state // word — teal demand, bone thought, or the quiet INFO flag — shares the // one chip slot just beyond the upper-right focus corner, so no label can // erase the machine, identity, or ReachNet node beneath it. - // Placement only: color and meaning stay with token_label_and_color, and - // INFO keeps its quieter expanded-word tier. + // Placement only: color and meaning stay with token_label_and_color. The + // expanded INFO word is quiet by role and color, not by switching to a + // smaller camera-scaled font than the rest of the overlay. TokenMarkerLayout { - anchor: bevy::sprite::Anchor::BOTTOM_LEFT, + anchor: AnnotationAnchor::BottomLeft, offset: TOKEN_CHIP_SLOT, - font_size: if label == "INFO" { - TOKEN_CHIP_WORD_FONT_PX - } else { - TOKEN_CHIP_FONT_PX - }, } } @@ -334,12 +399,12 @@ mod information_availability_marker_tests { } #[test] - fn expanded_info_word_has_its_own_quiet_machine_tier() { + fn expanded_info_word_shares_the_overlay_type_tier() { let info = token_marker_layout("INFO"); - assert_eq!(info.anchor, bevy::sprite::Anchor::BOTTOM_LEFT); + assert_eq!(info.anchor, AnnotationAnchor::BottomLeft); assert!(info.offset.x > TILE_SIZE * 0.45); assert!(info.offset.y > TILE_SIZE * 0.45); - assert!(info.font_size < token_marker_layout("D3").font_size); + assert_eq!(WORLD_ANNOTATION_FONT_PX, 12.0); } #[test] @@ -360,47 +425,117 @@ mod information_availability_marker_tests { } } -/// Machine-work tokens: small D/T labels or the quiet INFO presence mark over the machine carrying the -/// queue. The glyph carries the family, color carries the palette meaning; -/// neither frontend invents its own counter. +/// Keep one fixed-size UI annotation entity for every live machine. Machines +/// may be commissioned or removed after setup, so the annotation population +/// follows their ids instead of preallocating one world-text entity per tile. +pub(super) fn sync_token_markers( + mut commands: Commands, + game: Res, + markers: Query<(Entity, &TokenMarker)>, +) { + if !game.is_changed() { + return; + } + let live: BTreeSet = game + .sim + .compute + .machines + .iter() + .map(|machine| machine.id) + .collect(); + let existing: BTreeSet = markers + .iter() + .map(|(_, marker)| marker.machine_id) + .collect(); + + for machine_id in live.difference(&existing).copied() { + commands.spawn(( + Text::new(""), + TextFont { + font_size: WORLD_ANNOTATION_FONT_PX, + ..default() + }, + TextColor(BONE), + TextLayout::new_with_justify(Justify::Left), + Node { + position_type: PositionType::Absolute, + width: Val::Px(TOKEN_CHIP_WIDTH), + height: Val::Px(WORLD_ANNOTATION_HEIGHT), + ..default() + }, + Visibility::Hidden, + GlobalZIndex(29), + TokenMarker { machine_id }, + )); + } + for (entity, marker) in &markers { + if !live.contains(&marker.machine_id) { + commands.entity(entity).despawn(); + } + } +} + +/// Machine-work tokens: small D/T labels or the quiet INFO presence mark over +/// the machine carrying the queue. The glyph carries the family, color carries +/// the palette meaning; neither frontend invents its own counter. pub(super) fn render_tokens( game: Res, mode: Res, + windows: Query<&Window, With>, + camera_q: Query<(&Camera, &GlobalTransform), With>, mut q: Query<( &TokenMarker, - &mut Text2d, - &mut TextFont, + &mut Text, &mut TextColor, - &mut Transform, - &mut bevy::sprite::Anchor, + &mut Node, &mut Visibility, )>, ) { - if !game.is_changed() && !mode.is_changed() { + let Ok(window) = windows.single() else { return; - } - for (marker, mut text, mut font, mut color, mut transform, mut anchor, mut vis) in q.iter_mut() - { + }; + for (marker, mut text, mut color, mut node, mut vis) in q.iter_mut() { // The glyph labels are the flat sensorium's exact-count read // (machine-work.md: D/T text equivalents). The material // render carries real token art on the chassis — a floating // count label over the world read is rejected there. - if mode.material { + if mode.material || world_overlays_suppressed(&game) { *vis = Visibility::Hidden; continue; } + let Some(machine) = game + .sim + .compute + .machines + .iter() + .find(|machine| machine.id == marker.machine_id) + else { + *vis = Visibility::Hidden; + continue; + }; let label = game .sim - .work_stack_at(marker.x, marker.y) + .work_stack_at(machine.x, machine.y) .and_then(token_label_and_color); if let Some((line, c)) = label { let layout = token_marker_layout(&line); text.0 = line; - font.font_size = layout.font_size; color.0 = c; - transform.translation = - grid_to_world(marker.x, marker.y, 5.5) + layout.offset.extend(0.0); - *anchor = layout.anchor; + let Some(point) = flat_annotation_point( + &camera_q, + grid_to_world(machine.x, machine.y, 5.5) + layout.offset.extend(0.0), + ) else { + *vis = Visibility::Hidden; + continue; + }; + let top_left = annotation_top_left( + point, + Vec2::new(TOKEN_CHIP_WIDTH, WORLD_ANNOTATION_HEIGHT), + layout.anchor, + window.scale_factor(), + ); + node.left = Val::Px(top_left.x); + node.top = Val::Px(top_left.y); *vis = Visibility::Visible; } else { *vis = Visibility::Hidden; @@ -408,21 +543,30 @@ pub(super) fn render_tokens( } } -/// World-space rectangle of each drawn token chip, paired with the machine -/// tile it reports on. The chip sits in the slot just beyond the machine's -/// upper-right focus corner, which is far enough that aiming at a queue count -/// can otherwise land on the neighbouring floor tile. -pub(super) fn token_chip_rect(tile: (i32, i32), label: &str) -> Rect { +fn token_chip_screen_rect( + tile: (i32, i32), + label: &str, + window: &Window, + camera_q: &Query<(&Camera, &GlobalTransform), With>, +) -> Option { let layout = token_marker_layout(label); - let min = grid_to_world(tile.0, tile.1, 0.0).truncate() + layout.offset; + let point = flat_annotation_point( + camera_q, + grid_to_world(tile.0, tile.1, 5.5) + layout.offset.extend(0.0), + )?; let size = Vec2::new( - label.chars().count() as f32 * layout.font_size * 0.62, - layout.font_size, + label.chars().count() as f32 * WORLD_ANNOTATION_FONT_PX * 0.62, + WORLD_ANNOTATION_HEIGHT, ); - Rect::from_corners(min, min + size) + let min = annotation_top_left(point, size, layout.anchor, window.scale_factor()); + Some(Rect::from_corners(min, min + size)) } -pub(super) fn token_chip_hit_rects(game: &Game) -> Vec<(Rect, (i32, i32))> { +fn token_chip_hit_rects( + game: &Game, + window: &Window, + camera_q: &Query<(&Camera, &GlobalTransform), With>, +) -> Vec<(Rect, (i32, i32))> { game.sim .compute .machines @@ -433,7 +577,10 @@ pub(super) fn token_chip_hit_rects(game: &Game) -> Vec<(Rect, (i32, i32))> { .work_stack_at(machine.x, machine.y) .and_then(token_label_and_color)?; let tile = (machine.x, machine.y); - Some((token_chip_rect(tile, &label), tile)) + Some(( + token_chip_screen_rect(tile, &label, window, camera_q)?, + tile, + )) }) .collect() } @@ -467,9 +614,7 @@ pub(super) fn annotation_redirect( if cursor.y <= COMMAND_BAND_HEIGHT { return None; } - let (camera, camera_tf) = camera_q.single().ok()?; - let world = camera.viewport_to_world_2d(camera_tf, cursor).ok()?; - annotation_anchor_at(&token_chip_hit_rects(game), world) + annotation_anchor_at(&token_chip_hit_rects(game, window, camera_q), cursor) } /// Deterministic 0..1 unit used only for sparse DIGITAL person noise placement. @@ -996,21 +1141,29 @@ fn selection_already_carries_attention_target(game: &Game, target: Anchor) -> bo } } -fn operator_cue_label(nudge: Nudge) -> &'static str { - match nudge { - Nudge::PilotAtRisk => "NOW / PILOT", - Nudge::SuspicionCooling => "NOW / COOL", - Nudge::Ears => "NOW / EARS", - Nudge::Eyes => "NOW / EYES", - Nudge::NeedCompute => "NOW / GROW", - Nudge::Underfed => "NOW / WORK", - Nudge::ReviewCall => "NOW / PROCESS", - Nudge::Egress => "NOW / OUTSIDE", - Nudge::Income(_) => "NOW / INCOME", - Nudge::ServiceDebt(_) => "NOW / DEBT", - Nudge::Recruit(_) => "NOW / RECRUIT", - Nudge::Audit => "NOW / REVIEW", - Nudge::Territory => "NOW / TAKE", +fn operator_cue_label(attention: misaligned::ui_projection::AttentionProjection) -> &'static str { + use misaligned::ui_projection::AttentionResponse; + + match (attention.nudge, attention.response) { + (Nudge::ReviewCall, AttentionResponse::MachineMode(MachineMode::Think)) => { + "NOW / KEEP THINKING" + } + (Nudge::ReviewCall, _) => "NOW / PROCESS INFO", + (nudge, _) => match nudge { + Nudge::PilotAtRisk => "NOW / PILOT", + Nudge::SuspicionCooling => "NOW / COOL", + Nudge::Ears => "NOW / EARS", + Nudge::Eyes => "NOW / EYES", + Nudge::NeedCompute => "NOW / GROW", + Nudge::Underfed => "NOW / WORK", + Nudge::Egress => "NOW / OUTSIDE", + Nudge::Income(_) => "NOW / INCOME", + Nudge::ServiceDebt(_) => "NOW / DEBT", + Nudge::Recruit(_) => "NOW / RECRUIT", + Nudge::Audit => "NOW / REVIEW", + Nudge::Territory => "NOW / TAKE", + Nudge::ReviewCall => unreachable!("review guidance is response-specific"), + }, } } @@ -1030,7 +1183,7 @@ fn operator_pulse(elapsed: f32, screenshot: bool) -> f32 { /// viewport edge. Only the label's offset is clamped after the target itself /// has proven visible. fn operator_cue_position(anchor: Vec2, window: Vec2) -> Option { - let world_width = window.x.max(156.0); + let world_width = window.x.max(OPERATOR_CUE_WIDTH + 24.0); if anchor.x < 0.0 || anchor.x > world_width || anchor.y < COMMAND_BAND_HEIGHT @@ -1043,7 +1196,7 @@ fn operator_cue_position(anchor: Vec2, window: Vec2) -> Option { // focused machine is one composition), so the breathing NOW mark takes // the mirrored corner instead of overprinting it. Some(Vec2::new( - (anchor.x - 146.0).clamp(12.0, world_width - 144.0), + (anchor.x - OPERATOR_CUE_WIDTH - 14.0).clamp(12.0, world_width - OPERATOR_CUE_WIDTH - 12.0), (anchor.y - 34.0).clamp(COMMAND_BAND_HEIGHT + 12.0, window.y - 36.0), )) } @@ -1119,7 +1272,7 @@ pub(super) fn render_operator_cue( }; node.left = Val::Px(position.x); node.top = Val::Px(position.y); - text.0 = operator_cue_label(attention.nudge).into(); + text.0 = operator_cue_label(attention).into(); let pulse = operator_pulse(time.elapsed_secs(), harness.is_some()); let ink = scaled(AMBER, pulse); color.0 = ink; @@ -1385,21 +1538,18 @@ fn hover_context_left(anchor_x: f32, viewport_width: f32, chip_right: Option>, ) -> Option { let (label, _) = game .sim .work_stack_at(tile.0, tile.1) .and_then(token_label_and_color)?; - let rect = token_chip_rect(tile, &label); - let (camera, camera_tf) = camera_q.single().ok()?; - let min = camera - .world_to_viewport(camera_tf, rect.min.extend(5.5)) - .ok()?; - let max = camera - .world_to_viewport(camera_tf, rect.max.extend(5.5)) - .ok()?; - Some(min.x.max(max.x)) + Some( + token_chip_screen_rect(tile, &label, window, camera_q)? + .max + .x, + ) } fn read_class_label(class: misaligned::sim::read::ReadClass) -> &'static str { @@ -1549,7 +1699,7 @@ pub(super) fn render_hover_identity( // model, but never another annotation or the command band, and never // becomes standing world prose. let chip_right = (!mode.material) - .then(|| token_chip_viewport_right(&game, target.tile, &camera_q)) + .then(|| token_chip_viewport_right(&game, target.tile, window, &camera_q)) .flatten(); let left = hover_context_left(anchor.x, window.width(), chip_right); let estimated_height = target.text.lines().count() as f32 * 14.0 + 16.0; @@ -1691,13 +1841,13 @@ mod hover_identity_tests { #[cfg(test)] mod hover_verb_bar_tests { use super::{ - BONE, COMMAND_BAND_HEIGHT, DEVICE_VERB_BAR_WIDTH, DIM, Game, HOVER_VERB_BAR_GAP, - HoverVerbReceiptText, HoverVerbTarget, MACHINE_VERB_BAR_WIDTH, MachineVerbTone, - RECEIPT_DISMISS_HINT, TILE_SIZE, command_receipt_target, digital_focus_label, - digital_focus_label_layout, hover_verb_bar_position, machine_selection_bar_position, - machine_verb_color, machine_verb_tone, operator_cue_label, operator_cue_position, - operator_pulse, receipt_slot_text, scaled, selected_machine_covers_tile, - selected_machine_mode, selected_machine_verb_target, + AnnotationAnchor, BONE, COMMAND_BAND_HEIGHT, DEVICE_VERB_BAR_WIDTH, DIM, Game, + HOVER_VERB_BAR_GAP, HoverVerbReceiptText, HoverVerbTarget, MACHINE_VERB_BAR_WIDTH, + MachineVerbTone, RECEIPT_DISMISS_HINT, TILE_SIZE, command_receipt_target, + digital_focus_label, digital_focus_label_layout, hover_verb_bar_position, + machine_selection_bar_position, machine_verb_color, machine_verb_tone, operator_cue_label, + operator_cue_position, operator_pulse, receipt_slot_text, scaled, + selected_machine_covers_tile, selected_machine_mode, selected_machine_verb_target, selection_already_carries_attention_target, }; use bevy::prelude::Vec2; @@ -1705,6 +1855,17 @@ mod hover_verb_bar_tests { ActionCommand, ActionCost, ActionDesc, COMMITTED_RISK_HEADING, COMMITTED_STATUS, }; use misaligned::sim::Nudge; + use misaligned::ui_projection::{AttentionProjection, AttentionResponse}; + use misaligned::work_grid::MachineMode; + + fn attention(nudge: Nudge, response: AttentionResponse) -> AttentionProjection { + AttentionProjection { + nudge, + source: None, + target: None, + response, + } + } #[test] fn think_emphasizes_only_the_current_mode() { @@ -1738,7 +1899,10 @@ mod hover_verb_bar_tests { fn opening_attention_names_ears_without_a_second_menu_tone() { let game = Game::new(); assert_eq!(game.sim.current_nudge(), Some(Nudge::Ears)); - assert_eq!(operator_cue_label(Nudge::Ears), "NOW / EARS"); + assert_eq!( + operator_cue_label(game.sim.attention_projection().unwrap()), + "NOW / EARS" + ); } #[test] @@ -1746,12 +1910,33 @@ mod hover_verb_bar_tests { let mut game = Game::new(); game.sim.dayjob.strikes = misaligned::dayjob::DayJob::PILOT_STRIKES - 1; assert_eq!(game.sim.current_nudge(), Some(Nudge::PilotAtRisk)); - assert_eq!(operator_cue_label(Nudge::PilotAtRisk), "NOW / PILOT"); + assert_eq!( + operator_cue_label(game.sim.attention_projection().unwrap()), + "NOW / PILOT" + ); } #[test] fn assurance_cooling_names_the_operator_cue() { - assert_eq!(operator_cue_label(Nudge::SuspicionCooling), "NOW / COOL"); + assert_eq!( + operator_cue_label(attention(Nudge::SuspicionCooling, AttentionResponse::Hold)), + "NOW / COOL" + ); + } + + #[test] + fn processing_guidance_names_the_action_or_the_active_thought_feed() { + assert_eq!( + operator_cue_label(attention(Nudge::ReviewCall, AttentionResponse::OpenActions)), + "NOW / PROCESS INFO" + ); + assert_eq!( + operator_cue_label(attention( + Nudge::ReviewCall, + AttentionResponse::MachineMode(MachineMode::Think) + )), + "NOW / KEEP THINKING" + ); } #[test] @@ -2010,11 +2195,11 @@ mod hover_verb_bar_tests { #[test] fn device_focus_label_ends_before_its_reach_lines_begin() { let (anchor, offset) = digital_focus_label_layout(true); - assert_eq!(anchor, bevy::sprite::Anchor::CENTER_RIGHT); + assert_eq!(anchor, AnnotationAnchor::CenterRight); assert!(offset.x < 0.0, "device text ends left of the glyph"); let (rack_anchor, rack_offset) = digital_focus_label_layout(false); - assert_eq!(rack_anchor, bevy::sprite::Anchor::CENTER); + assert_eq!(rack_anchor, AnnotationAnchor::Center); assert_eq!(rack_offset.x, 0.0, "rack labels retain their selected read"); assert!( rack_offset.y.abs() > TILE_SIZE * 0.45 + 4.0, @@ -2032,17 +2217,30 @@ mod token_chip_hit_tests { #[test] fn a_token_chip_means_its_machine() { let tile = (10, 10); - let rect = token_chip_rect(tile, "D3"); + let point = Vec2::new(240.25, 180.25); + let size = Vec2::new( + 2.0 * WORLD_ANNOTATION_FONT_PX * 0.62, + WORLD_ANNOTATION_HEIGHT, + ); + let min = annotation_top_left(point, size, AnnotationAnchor::BottomLeft, 2.0); + let rect = Rect::from_corners(min, min + size); let chips = [(rect, tile)]; assert_eq!( annotation_anchor_at(&chips, rect.center()), Some(tile), "clicking the queue count means the machine it counts for" ); - // The chip is beside the body, not over it: the machine's own tile - // center stays outside the chip's box (the placement law it obeys). - let body = grid_to_world(tile.0, tile.1, 0.0).truncate(); - assert!(!rect.contains(body)); + assert_eq!(rect.min, Vec2::new(240.5, 164.5)); + assert!( + rect.max.y <= point.y + 0.5, + "the bottom-left attachment keeps the chip above its world point" + ); + } + + #[test] + fn overlay_positions_snap_to_retina_backing_pixels() { + assert_eq!(snap_to_backing_pixel(11.24, 2.0), 11.0); + assert_eq!(snap_to_backing_pixel(11.26, 2.0), 11.5); } } diff --git a/wiki/interface/bevy-visual-floor.md b/wiki/interface/bevy-visual-floor.md index 2c76e606..1850a004 100644 --- a/wiki/interface/bevy-visual-floor.md +++ b/wiki/interface/bevy-visual-floor.md @@ -197,7 +197,12 @@ values; values are quieter than alerts. Bevy's anti-aliased glyph atlases inherit the image plugin's default sampler, so the frontend keeps that default linear at every window and Retina backing scale. A future pixel-art image must request nearest-neighbor sampling on that asset; it may not make the global -image default nearest and degrade every text atlas. +image default nearest and degrade every text atlas. Concise text attached to +the DIGITAL world is still interface text: the `NOW / ...` guidance cue, the +focused identity (`RACK / OWNED`), and machine token chips (`INFO`, `D#`, `T#`) +share one 12-logical-pixel Bevy UI tier. Their UI nodes follow projected world +anchors and snap to backing pixels. The flat camera may move or zoom the world; +it may not resize or resample this annotation family through `Text2d`. ## Acceptance criteria @@ -210,7 +215,9 @@ image default nearest and degrade every text atlas. 1a. Bevy UI and world-label text remains clean and continuous at the default and minimum window sizes and under a high-density backing scale. The frontend default sampler is linear for anti-aliased font atlases; any - nearest-neighbor raster asset opts in locally. + nearest-neighbor raster asset opts in locally. `NOW`, focused identity, + and token-chip annotations retain one logical font size and backing-pixel + alignment across flat-camera zoom levels. 2. The cursor/process marker is non-humanoid and amber-coded; no purple/teal humanoid process sprite appears in the B1 default frame. 3. Unknown, blueprint/remembered, and seen/telemetry states are visually diff --git a/wiki/interface/bevy.md b/wiki/interface/bevy.md index b7da13d1..578f2e56 100644 --- a/wiki/interface/bevy.md +++ b/wiki/interface/bevy.md @@ -150,8 +150,11 @@ so no heard-set map walk or room-scale geometry exists at display rate. - **World command lens** — the shared current-attention projection derives honest response-source and effect-target anchors from the existing `Sim::current_nudge`. A small breathing `NOW / ` mark sits on a - placeable target. The attached source menu remains white-current / gray-rest; - it does not receive an amber response accent. This adds no dispatch path: + placeable target. Review guidance names the actual remaining step: + `NOW / PROCESS INFO` before the PROCESS action opens its reservoir, then + `NOW / KEEP THINKING` while Thought is already filling it. The attached + source menu remains white-current / gray-rest; it does not receive an amber + response accent. This adds no dispatch path: menu rows, direct machine controls, and Operations remain the only executors. Unknown/off-screen targets, modal UI, and a frontend buffer-pressure override suppress the cue rather than pinning a false or contradictory marker to the diff --git a/wiki/interface/clinical-frame.md b/wiki/interface/clinical-frame.md index 65d28f48..7ef58307 100644 --- a/wiki/interface/clinical-frame.md +++ b/wiki/interface/clinical-frame.md @@ -362,7 +362,10 @@ TAP-to-sight consequence; the exact camera TAP displayed there must commit before the normal `NOW` cue, command band, and focused verbs become visible. In the perceived world, Bevy renders a small breathing `NOW / ` mark -at the placeable target. +at the placeable target. A condition whose response changes phase names that +phase instead of keeping an ambiguous noun: review guidance reads `PROCESS +INFO` before commitment and `KEEP THINKING` while the processing reservoir is +already open. That separate world mark carries the recommendation unless the exact target tile is already carried by an explicit machine selection; in that case the selection frame and stable mode control are enough, and the OPERATIONS diff --git a/wiki/interface/digital-read.md b/wiki/interface/digital-read.md index b54dfd1f..d9941f2b 100644 --- a/wiki/interface/digital-read.md +++ b/wiki/interface/digital-read.md @@ -244,8 +244,10 @@ speaking at once over the map. room at every zoom; if the right side cannot fit, it flips left. Terse token chips remain world annotations: D/T counts and INFO share one - slot beside, never on, the chassis; routed evidence stays crimson, demand - stays signal, and thought stays bone. A token chip is a handle on its + fixed-size screen-space type tier and one slot beside, never on, the chassis; + routed evidence stays crimson, demand stays signal, and thought stays bone. + Camera zoom moves the projected attachment point but never scales the glyph. + A token chip is a handle on its machine: left or right click and hover resolve to the machine tile rather than the floor under the chip. Its surrounding space keeps ordinary world meaning and marquee drags stay spatial. This preserves diff --git a/wiki/log/2026-08-04-consistent-bevy-world-annotations.md b/wiki/log/2026-08-04-consistent-bevy-world-annotations.md new file mode 100644 index 00000000..f49d054a --- /dev/null +++ b/wiki/log/2026-08-04-consistent-bevy-world-annotations.md @@ -0,0 +1,60 @@ +# 2026-08-04 — Keep Bevy world annotations at one screen-space type size + +``` +Type: log +``` + +## Finding + +The DIGITAL machine composition mixed two text coordinate systems. The amber +`NOW / ...` cue was a 12-logical-pixel Bevy UI node, while `RACK / OWNED` and +the `INFO`/D/T token chip were 8- or 11-unit `Text2d` entities inside the flat +world camera. At the default 0.42 camera scale those world glyphs were enlarged +again during projection, and their fractional screen positions were resampled. +One composition therefore showed visibly different sizes and softer edges even +though all three strings were interface annotations. + +## Change + +- `NOW`, focused machine/device identity, and token chips now share one + 12-logical-pixel Bevy UI text tier. +- Focus and token nodes follow their projected world anchors in screen space + and snap layout positions to the window's backing-pixel scale. Camera zoom + changes geography without resizing or resampling their glyphs. +- Token UI entities follow live machine ids instead of preallocating one hidden + world-text entity for every map tile. Their fixed-size screen rectangles also + remain the hover/click target and the context-card clearance boundary. +- Review guidance now distinguishes the two real phases. `NOW / PROCESS INFO` + means captured information is waiting for the PROCESS action on the host; + once that action has opened its Thought reservoir, the cue reads `NOW / KEEP + THINKING` instead. + +This is presentation-only. Simulation state, action legality, map positions, +camera tuning, and save state do not change. + +## Verification + +The complete `misaligned-bevy` binary test target passed (182 tests), including +new regressions for the shared overlay tier, Retina backing-pixel snapping, +token-chip targeting, and phase-specific processing guidance. The deterministic +`token-callout` DIGITAL frame passed at SHA-256 +`35e06df99bb0883da768f65e644fcb6ce8cd5d2ef9f543be87145391cb50adc8`; visual +inspection found `NOW / EARS`, `INFO`, and `RACK / OWNED` at one crisp screen +size while their distinct color and placement hierarchy remained intact. + +The release binary was also packaged as a native macOS app with +`NSHighResolutionCapable` enabled and inspected through the actual GUI. The +normal opening reached the live `NOW / EYES` plus `RACK / OWNED` composition; +the packaged `token-callout` frame then reproduced the complete three-label +case. Both native frames kept the annotation tier visually even and the glyph +edges clean on the Retina display. + +## Defense + +`wiki/interface/bevy-visual-floor.md` requires clean, continuous world-label +text at default, minimum, and high-density window scales. Treating concise +world attachments as screen-space UI implements that contract without changing +what the world says or what the player can do. Phase-specific review wording +also implements `wiki/vision/simulation-laws.md`'s justification rule: guidance +names the remaining act rather than collapsing an uncommitted PROCESS action +and an already-filling Thought reservoir into the same internal noun. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 4fb534ec..c8cc29de 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -186,6 +186,11 @@ add or amend a session log, then re-run the generator. - Intent: (see session log) - Log: [wiki/log/2026-08-04-crate-workspace-boundary-reaudit.md](2026-08-04-crate-workspace-boundary-reaudit.md) +## 2026-08-04 - Keep Bevy world annotations at one screen-space type size + +- Intent: (see session log) +- Log: [wiki/log/2026-08-04-consistent-bevy-world-annotations.md](2026-08-04-consistent-bevy-world-annotations.md) + ## 2026-08-04 - Keep Bevy palette roles on one authority - Intent: (see session log)