//! LUMON — Macrodata Refine //! //! Windowed recreation of the Lumon box-drawing game: a vast field of glowing, //! breathing numbers. Left-drag boxes them in; boxes that touch merge into one. //! The numbers glow (layered bloom) and grow/shrink in size; the selected box's //! numbers glow hardest and its border pulses. //! //! Input is polled every frame from the windowing layer, so selection works //! regardless of what motion events a terminal would have sent — press //! anchors, the position at release always defines the box. use std::collections::HashMap; use std::collections::HashSet; use macroquad::prelude::*; mod sfx; use sfx::{Sfx, SfxId}; const WORLD_COLS: i32 = 160; const WORLD_ROWS: i32 = 120; /// Unscaled cell size in "world units"; everything scales by `zoom`. const CELL_W: f32 = 26.0; const CELL_H: f32 = 34.0; /// Base font size the glyph metrics are measured at. const GLYPH_BASE: u16 = 24; const ZOOM_MIN: f32 = 0.55; const ZOOM_MAX: f32 = 2.2; /// Box palette as RGB triples. const BOX_COLORS: [[f32; 3]; 8] = [ [0.30, 0.90, 1.00], // cyan [1.00, 0.45, 0.85], // magenta [0.40, 1.00, 0.60], // green [1.00, 0.85, 0.30], // yellow [0.45, 0.60, 1.00], // blue [1.00, 0.45, 0.40], // red [1.00, 0.65, 0.30], // orange [0.75, 0.50, 1.00], // purple ]; const FIELD_RGB: [f32; 3] = [0.40, 0.50, 0.62]; const SUSPECT_RGB: [f32; 3] = [1.00, 0.82, 0.25]; const SELECT_RGB: [f32; 3] = [0.95, 0.98, 1.00]; const MAX_UNDO: usize = 200; // --- hidden temper map ------------------------------------------------------ /// Cell tags: 0..=3 = WOE/DREAD/MALICE/FROCLIC cluster, 4 = untagged. const TEMPER_NONE: u8 = 4; /// Sheet seed for shift 1; later shifts derive from it. const BASE_SEED: u64 = 0x5EED_C0DE_F00D_BEEF; /// Quota grows each shift: 400, 550, 700, ... const QUOTA_BASE: u32 = 400; const QUOTA_STEP: u32 = 150; /// How long refined temper siblings keep pulsing on the sheet. const TRAIL_SECS: f32 = 5.0; /// Length of the shift-complete freeze before the next sheet loads. const SETTLE_SECS: f32 = 4.0; /// Refinement sigils are N x N silhouettes of the refined region. const SIGIL_N: usize = 8; #[derive(PartialEq, Clone, Copy, Debug)] enum Phase { Work, /// Quota met: the sheet settles and a new one is about to load. Settling, } type Cell = (i32, i32); // (row, col) struct MergedBox { id: u64, cells: HashSet, color: [f32; 3], /// Grabbed with Space: the box is filed as refined output and goes quiet. refined: bool, } /// One entry in the refinement output panel. struct Refinement { index: u64, mood: &'static str, line: &'static str, score: f32, digit_sum: u32, digit_count: u32, letter_count: u32, dominant: char, hue: [f32; 3], at: f32, // tick when grabbed (drives slide-in) /// Downsampled silhouette of the refined region (sigil_n * sigil_n). sigil: Vec, sigil_n: usize, } /// The four tempers, each with a deterministic classification and flavor lines. const TEMPERS: [(&str, [&str; 3]); 4] = [ ( "WOE", [ "The numbers wept. Refinement logged.", "A quiet sadness settles into the grid.", "Mood signature: elegiac. Filed.", ], ), ( "DREAD", [ "A cold certainty spreads across the sheet.", "Something is coming. The digits know.", "Mood signature: foreboding. Filed.", ], ), ( "MALICE", [ "The data resists. It always resists.", "A thin cruelty hums beneath the figures.", "Mood signature: hostile. Filed.", ], ), ( "FROCLIC", [ "Brief, inexplicable delight. Filed.", "For one moment, the numbers danced.", "Mood signature: whimsical. Filed.", ], ), ]; fn mood_color(mood: &str) -> [f32; 3] { match mood { "WOE" => [0.45, 0.60, 1.00], "DREAD" => [0.65, 0.70, 0.78], "MALICE" => [1.00, 0.45, 0.40], _ => [1.00, 0.85, 0.30], } } // --------------------------------------------------------------------------- // Settings (`,` opens the panel; persisted to lumongame.cfg) // --------------------------------------------------------------------------- /// Picker labels. Index 0 is the built-in bitmap font; 1..=4 are the TTFs /// embedded below. const FONT_NAMES: [&str; 5] = [ "BUILT-IN PIXEL", "JETBRAINS MONO", "SPACE MONO", "IBM PLEX MONO", "FIRA MONO", ]; const GLOW_NAMES: [&str; 4] = ["off", "subtle", "normal", "bright"]; /// Halo alpha behind glyphs per glow level (index 2 keeps the classic look). const HALO_ALPHA: [f32; 4] = [0.0, 0.04, 0.08, 0.16]; /// How far the bright core pushes toward white per glow level. const CORE_LIFT: [f32; 4] = [0.0, 0.20, 0.45, 0.70]; #[derive(Clone, Copy)] struct Settings { /// 0 = built-in bitmap, 1..=4 = FONT_NAMES / FONT_FILES. font: usize, /// 0 off .. 3 bright. glow: usize, scanlines: bool, sound: bool, } impl Default for Settings { fn default() -> Self { Settings { font: 1, glow: 2, scanlines: true, sound: true, } } } impl Settings { fn load() -> Self { let mut s = Settings::default(); if let Ok(txt) = std::fs::read_to_string("lumongame.cfg") { for line in txt.lines() { let Some((k, v)) = line.split_once('=') else { continue; }; match (k.trim(), v.trim()) { ("font", v) => { if let Ok(n) = v.parse::() { s.font = n.min(FONT_NAMES.len() - 1); } } ("glow", v) => { if let Ok(n) = v.parse::() { s.glow = n.min(GLOW_NAMES.len() - 1); } } ("scanlines", v) => s.scanlines = v == "1", ("sound", v) => s.sound = v == "1", _ => {} } } } s } fn save(&self) { let _ = std::fs::write( "lumongame.cfg", format!( "font={}\nglow={}\nscanlines={}\nsound={}\n", self.font, self.glow, self.scanlines as u8, self.sound as u8 ), ); } } /// Deterministic xorshift64* so the macrodata sheet is identical every run. struct Rng(u64); impl Rng { fn new(seed: u64) -> Self { // xorshift needs a non-zero state; keep every other seed distinct // (forcing |1 here would make BASE_SEED and BASE_SEED^1 identical). Rng(if seed == 0 { 0x9E37_79B9_7F4A_7C15 } else { seed }) } fn next_u64(&mut self) -> u64 { let mut x = self.0; x ^= x >> 12; x ^= x << 25; x ^= x >> 27; self.0 = x; x.wrapping_mul(0x2545_F491_4F6C_DD1D) } fn below(&mut self, n: u64) -> u64 { self.next_u64() % n } } struct Game { chars: Vec, /// Suspect numbers ("the ones that feel wrong") — they flicker yellow. suspect: Vec, /// Hidden temper tags per cell (TEMPER_NONE = untagged). temper: Vec, boxes: Vec, undo: Vec>, next_id: u64, color_idx: usize, selected_id: Option, /// Camera in screen pixels. World point `wu` draws at `wu * zoom - cam`. cam: (f32, f32), zoom: f32, /// Left selection: (anchor cell, current cell). left_drag: Option<(Cell, Cell)>, right_drag: bool, last_mouse: (f32, f32), /// Keyboard box-cursor fallback ('b'). cursor_mode: bool, cursor: Cell, anchor: Option, /// Filed refinement outputs, newest first. refinements: Vec, next_refine: u64, /// Quota loop: refine to fill the bar, the sheet settles, next shift. base_seed: u64, shift_no: u32, shift_mass: u32, quota_target: u32, phase: Phase, settle_until: f32, /// After refining a temper cluster, hidden siblings pulse for a while. trail_temper: Option, trail_until: f32, settings: Settings, /// Indexed like FONT_NAMES; None = built-in or failed load (fallback). fonts: Vec>, /// Synthesized sound effects (tones, not music). sfx: Sfx, settings_open: bool, /// Focused settings row: 0 font, 1 glow, 2 scanlines, 3 sound. settings_focus: usize, /// Seconds since start — drives all animation. tick: f32, /// Time of last commit; a quick grow-pop radiates from the selection. pop_at: f32, status: String, status_at: f32, frames: u32, } fn generate_field(seed: u64) -> (Vec, Vec, Vec) { let mut rng = Rng::new(seed); let n = (WORLD_ROWS * WORLD_COLS) as usize; let mut chars = Vec::with_capacity(n); let mut suspect = vec![false; n]; let digits: Vec = "0123456789".chars().collect(); let letters: Vec = "ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); for slot in suspect.iter_mut() { let roll = rng.below(100); let ch = if roll < 78 { digits[rng.below(digits.len() as u64) as usize] } else { letters[rng.below(letters.len() as u64) as usize] }; chars.push(ch); *slot = rng.below(100) < 2 && ch.is_ascii_digit(); } // Hidden temper map: scattered clusters with distance falloff. The // mood of a refined box is read from these tags, so the output carries // real information about what you boxed — never rendered as text. let mut temper = vec![TEMPER_NONE; n]; for _ in 0..26 { let t = rng.below(4) as u8; let r0 = rng.below(WORLD_ROWS as u64) as i32; let c0 = rng.below(WORLD_COLS as u64) as i32; let radius = 3 + rng.below(6) as i32; // 3..=8 for r in (r0 - radius).max(0)..=(r0 + radius).min(WORLD_ROWS - 1) { for c in (c0 - radius).max(0)..=(c0 + radius).min(WORLD_COLS - 1) { let d = (r - r0).abs().max((c - c0).abs()); if d > radius { continue; } // Dense core, sparse edge. let keep = 100 - (d * 100 / (radius + 1)); if rng.below(100) < keep as u64 { let idx = (r * WORLD_COLS + c) as usize; temper[idx] = t; // Suspects bias toward cluster interiors — the flickering // yellows mark where meaning lives. if d <= radius / 2 && rng.below(100) < 6 && chars[idx].is_ascii_digit() { suspect[idx] = true; } } } } } (chars, suspect, temper) } fn phase_of(r: i32, c: i32) -> f32 { let mut h = (r as u32).wrapping_mul(374_761_393) ^ (c as u32).wrapping_mul(668_265_263); h = (h ^ (h >> 13)).wrapping_mul(1_274_126_177); h ^= h >> 16; h as f32 / u32::MAX as f32 * std::f32::consts::TAU } fn touches(a: &HashSet, b: &HashSet) -> bool { for &(r, c) in a { if b.contains(&(r, c)) || b.contains(&(r - 1, c)) || b.contains(&(r + 1, c)) || b.contains(&(r, c - 1)) || b.contains(&(r, c + 1)) { return true; } } false } /// Split a cell set into 4-connected components — a carve that cuts a box /// in two leaves two independent boxes. fn connected_components(cells: &HashSet) -> Vec> { let mut seen: HashSet = HashSet::new(); let mut comps = Vec::new(); for &start in cells { if seen.contains(&start) { continue; } let mut comp = HashSet::new(); let mut stack = vec![start]; seen.insert(start); while let Some((r, c)) = stack.pop() { comp.insert((r, c)); for n in [(r - 1, c), (r + 1, c), (r, c - 1), (r, c + 1)] { if cells.contains(&n) && seen.insert(n) { stack.push(n); } } } comps.push(comp); } comps } fn quota_target_for(shift: u32) -> u32 { QUOTA_BASE + (shift.saturating_sub(1)) * QUOTA_STEP } /// Downsample a region's cells into an N x N silhouette — the sigil that /// represents this refinement in the output gallery. fn sigil_of(cells: &[Cell]) -> (Vec, usize) { let (mut rmin, mut rmax, mut cmin, mut cmax) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); for &(r, c) in cells { rmin = rmin.min(r); rmax = rmax.max(r); cmin = cmin.min(c); cmax = cmax.max(c); } let set: HashSet = cells.iter().copied().collect(); let h = (rmax - rmin + 1).max(1) as usize; let w = (cmax - cmin + 1).max(1) as usize; let mut bits = vec![false; SIGIL_N * SIGIL_N]; for r in 0..SIGIL_N { for c in 0..SIGIL_N { // Sample the center of each band. let sr = rmin + ((r * 2 + 1) * h / (SIGIL_N * 2)) as i32; let sc = cmin + ((c * 2 + 1) * w / (SIGIL_N * 2)) as i32; bits[r * SIGIL_N + c] = set.contains(&(sr, sc)); } } (bits, SIGIL_N) } impl Game { fn new(fonts: Vec>) -> Self { let (chars, suspect, temper) = generate_field(BASE_SEED); let settings = Settings::load(); Game { chars, suspect, temper, boxes: Vec::new(), undo: Vec::new(), next_id: 0, color_idx: 0, selected_id: None, cam: (0.0, 0.0), zoom: 1.0, left_drag: None, right_drag: false, last_mouse: (0.0, 0.0), cursor_mode: false, cursor: (0, 0), anchor: None, refinements: Vec::new(), next_refine: 1, base_seed: BASE_SEED, shift_no: 1, shift_mass: 0, quota_target: quota_target_for(1), phase: Phase::Work, settle_until: 0.0, trail_temper: None, trail_until: 0.0, settings, fonts, sfx: Sfx::default(), settings_open: false, settings_focus: 0, tick: 0.0, pop_at: -10.0, status: String::new(), status_at: -10.0, frames: 0, } } fn active_font(&self) -> Option<&Font> { self.fonts.get(self.settings.font).and_then(|f| f.as_ref()) } fn settling(&self) -> bool { self.phase == Phase::Settling } fn temper_at(&self, row: i32, col: i32) -> u8 { if (0..WORLD_ROWS).contains(&row) && (0..WORLD_COLS).contains(&col) { self.temper[(row * WORLD_COLS + col) as usize] } else { TEMPER_NONE } } /// Quota met: the shift freezes, then a fresh sheet loads. fn next_shift(&mut self) { self.shift_no += 1; let seed = self .base_seed ^ (self.shift_no as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); let (chars, suspect, temper) = generate_field(seed); self.chars = chars; self.suspect = suspect; self.temper = temper; self.boxes.clear(); self.undo.clear(); self.selected_id = None; self.left_drag = None; self.anchor = None; self.cursor_mode = false; self.shift_mass = 0; self.quota_target = quota_target_for(self.shift_no); self.trail_temper = None; self.pop_at = -10.0; self.status.clear(); self.status_at = -10.0; self.phase = Phase::Work; self.flash(format!("shift {} - a new sheet arrives", self.shift_no)); self.sfx.play(SfxId::Sheet, 0.7); } fn char_at(&self, row: i32, col: i32) -> char { if (0..WORLD_ROWS).contains(&row) && (0..WORLD_COLS).contains(&col) { self.chars[(row * WORLD_COLS + col) as usize] } else { ' ' } } fn suspect_at(&self, row: i32, col: i32) -> bool { (0..WORLD_ROWS).contains(&row) && (0..WORLD_COLS).contains(&col) && self.suspect[(row * WORLD_COLS + col) as usize] } fn flash(&mut self, msg: impl Into) { self.status = msg.into(); self.status_at = self.tick; } fn push_undo(&mut self) { self.undo.push( self.boxes .iter() .map(|b| MergedBox { id: b.id, cells: b.cells.clone(), color: b.color, refined: b.refined, }) .collect(), ); if self.undo.len() > MAX_UNDO { self.undo.remove(0); } } fn undo_last(&mut self) { if let Some(prev) = self.undo.pop() { self.boxes = prev; self.selected_id = self.boxes.last().map(|b| b.id); self.flash("undo"); self.sfx.play(SfxId::Undo, 0.7); } else { self.flash("nothing to undo"); } } fn clear_boxes(&mut self) { if self.boxes.is_empty() { self.flash("no boxes to clear"); return; } self.push_undo(); self.boxes.clear(); self.selected_id = None; self.flash("cleared"); self.sfx.play(SfxId::Clear, 0.7); } fn rect_cells(a: Cell, b: Cell) -> HashSet { let (r0, r1) = (a.0.min(b.0), a.0.max(b.0)); let (c0, c1) = (a.1.min(b.1), a.1.max(b.1)); let mut set = HashSet::new(); for r in r0..=r1 { for c in c0..=c1 { set.insert((r, c)); } } set } /// Commit a drawn box. /// /// - Reselecting a box's exact rectangle toggles it off (remove). /// - A selection lying entirely inside existing boxes is a **carve**: /// only the selected cells are removed from those boxes; the rest of /// each box stays (splitting into separate boxes if the cut /// disconnects it). No new box is drawn. /// - Otherwise the selection draws a new box that merges with whatever /// it touches. fn commit_box(&mut self, a: Cell, b: Cell) { let cells = Self::rect_cells(a, b); if cells.is_empty() { return; } // Exact re-selection -> toggle that box off. if let Some(pos) = self.boxes.iter().position(|bx| bx.cells == cells) { self.push_undo(); let removed = self.boxes.remove(pos); if self.selected_id == Some(removed.id) { self.selected_id = self.boxes.last().map(|bx| bx.id); } if std::env::var("LUMON_DEBUG").is_ok() { eprintln!("REMOVE id={} area={}", removed.id, removed.cells.len()); } self.flash(format!("removed box ({} chars)", removed.cells.len())); self.sfx.play(SfxId::Remove, 0.8); return; } self.push_undo(); // Is every selected cell already inside some box? Then this is a // carve gesture: bite the subset out of the boxes that contain it. let boxed_now: HashSet = self .boxes .iter() .flat_map(|bx| bx.cells.iter().copied()) .collect(); if cells.is_subset(&boxed_now) { let mut carved = 0usize; let mut trimmed = 0usize; let mut splits = 0usize; let mut i = 0; while i < self.boxes.len() { if self.boxes[i].cells.is_disjoint(&cells) { i += 1; continue; } let bx = self.boxes.remove(i); carved += bx.cells.intersection(&cells).count(); if self.selected_id == Some(bx.id) { self.selected_id = None; } let remaining: HashSet = bx.cells.difference(&cells).copied().collect(); if remaining.is_empty() { continue; // fully carved away } trimmed += 1; let comps = connected_components(&remaining); splits += comps.len().saturating_sub(1); for comp in comps { let id = self.next_id; self.next_id += 1; self.boxes.push(MergedBox { id, cells: comp, color: bx.color, refined: bx.refined, }); } } self.pop_at = self.tick; if std::env::var("LUMON_DEBUG").is_ok() { eprintln!("CARVE carved={carved} trimmed={trimmed} splits={splits}"); } let mut msg = format!("carved {carved} chars from {trimmed} box(es)"); if splits > 0 { msg.push_str(&format!(" · split x{splits}")); } self.flash(msg); self.sfx.play(SfxId::Carve, 0.8); return; } // Normal draw: the selection becomes a new box. let color = BOX_COLORS[self.color_idx % BOX_COLORS.len()]; self.color_idx += 1; let id = self.next_id; self.next_id += 1; let mut merged = MergedBox { id, cells, color, refined: false, }; // Touching (or contained) boxes merge into it. let mut absorbed = 0usize; let mut i = 0; while i < self.boxes.len() { if touches(&merged.cells, &self.boxes[i].cells) { let other = self.boxes.remove(i); absorbed += 1; merged.cells.extend(other.cells); } else { i += 1; } } let area = merged.cells.len(); if std::env::var("LUMON_DEBUG").is_ok() { eprintln!("COMMIT id={id} area={area} absorbed={absorbed}"); } self.selected_id = Some(id); self.pop_at = self.tick; self.boxes.push(merged); let mut msg = format!("box drawn ({area} chars)"); if absorbed > 0 { msg.push_str(&format!(" · merged x{absorbed}")); } self.flash(msg); if absorbed > 0 { self.sfx.play(SfxId::Merge, 0.85); } else { self.sfx.play(SfxId::Draw, 0.7); } } fn enclosed_total(&self) -> usize { self.boxes.iter().map(|b| b.cells.len()).sum() } /// Space: grab the current selection — read its hidden temper, extract /// its data, file it in the output panel, and feed the quota. The box /// is stamped refined and goes quiet. fn grab_selection(&mut self) { let Some(id) = self.selected_id else { self.flash("nothing to refine - draw a box first"); return; }; let Some(pos) = self.boxes.iter().position(|b| b.id == id) else { return; }; // Deterministic hash over the selected cells: coords + glyphs. let mut cells: Vec = self.boxes[pos].cells.iter().copied().collect(); cells.sort_unstable(); let mut h: u32 = 0x811C_9DC5; let mut digit_sum = 0u32; let mut digit_count = 0u32; let mut letter_count = 0u32; let mut freq: HashMap = HashMap::new(); let mut temper_hits = [0u32; 4]; for &(r, c) in &cells { let ch = self.char_at(r, c); h = h .wrapping_mul(31) .wrapping_add((r as u32).wrapping_mul(7919) ^ (c as u32) ^ (ch as u32)); if ch.is_ascii_digit() { digit_sum += ch as u32 - '0' as u32; digit_count += 1; } else if ch.is_ascii_uppercase() { letter_count += 1; } *freq.entry(ch).or_default() += 1; let t = self.temper_at(r, c); if t < TEMPER_NONE { temper_hits[t as usize] += 1; } } let dominant = freq .iter() .max_by_key(|&(_, n)| *n) .map(|(c, _)| *c) .unwrap_or('?'); // The hidden temper map drives the mood when the selection covers // enough tagged cells; otherwise fall back to the hash. The output // carries real information about what you boxed — never as text. let tagged: u32 = temper_hits.iter().sum(); let dom_t = temper_hits .iter() .enumerate() .max_by_key(|&(_, n)| *n) .map(|(i, _)| i) .unwrap_or(0); let temper_hit = tagged >= 4 && temper_hits[dom_t] * 3 >= tagged * 2; for &t in &temper_hits { h = h.wrapping_mul(31).wrapping_add(t); } let (mood, lines) = if temper_hit { TEMPERS[dom_t] } else { TEMPERS[(h % 4) as usize] }; let line = lines[((h >> 3) % 3) as usize]; let score = 62.0 + ((h >> 6) % 380) as f32 / 10.0; // 62.0 .. 99.9 let (sigil, sigil_n) = sigil_of(&cells); let area = cells.len() as u32; let hue = self.boxes[pos].color; self.refinements.insert( 0, Refinement { index: self.next_refine, mood, line, score, digit_sum, digit_count, letter_count, dominant, hue, at: self.tick, sigil, sigil_n, }, ); if self.refinements.len() > 7 { self.refinements.pop(); } let index = self.next_refine; self.next_refine += 1; self.boxes[pos].refined = true; self.selected_id = None; self.pop_at = self.tick; // Sibling clusters of the same temper pulse on the sheet — a trail // to follow, never a translation. if temper_hit { self.trail_temper = Some(dom_t as u8); self.trail_until = self.tick + TRAIL_SECS; } // Quota: filed mass feeds the bar; a met quota settles the shift. self.shift_mass += area; let quota_met = self.shift_mass >= self.quota_target; if quota_met { self.phase = Phase::Settling; self.settle_until = self.tick + SETTLE_SECS; } if std::env::var("LUMON_DEBUG").is_ok() { eprintln!( "REFINE index={index} mood={mood} temper_hit={temper_hit} tagged={tagged} area={area} quota={}/{}", self.shift_mass, self.quota_target ); } let msg = if quota_met { format!("quota met - shift complete · {mood} {score:.1}%") } else { format!( "refined: {mood} {score:.1}% -> #{index:03} · quota {}/{}", self.shift_mass, self.quota_target ) }; self.flash(msg); if quota_met { self.sfx.play(SfxId::Quota, 0.9); } else { self.sfx.play(SfxId::Refine, 0.8); } if temper_hit { self.sfx.play(SfxId::Trail, 0.5); } } /// Terminal position -> world cell (clamped to the field). fn cell_at(&self, sx: f32, sy: f32) -> Cell { let wu = ((sx + self.cam.0) / self.zoom, (sy + self.cam.1) / self.zoom); let c = (wu.0 / CELL_W).floor() as i32; let r = (wu.1 / CELL_H).floor() as i32; (r.clamp(0, WORLD_ROWS - 1), c.clamp(0, WORLD_COLS - 1)) } fn clamp_cam(&mut self, view_w: f32, view_h: f32) { let ww = WORLD_COLS as f32 * CELL_W * self.zoom; let wh = WORLD_ROWS as f32 * CELL_H * self.zoom; self.cam.0 = self.cam.0.clamp(0.0, (ww - view_w).max(0.0)); self.cam.1 = self.cam.1.clamp(0.0, (wh - view_h).max(0.0)); } fn cell_size(&self) -> (f32, f32) { (CELL_W * self.zoom, CELL_H * self.zoom) } } // --------------------------------------------------------------------------- // Glyph drawing — integer pixel sizes (macroquad blurs when font_scale // drifts from 1.0), one tight halo for glow, per-cell scale for growth // --------------------------------------------------------------------------- /// Split a target pixel size into an integer raster size and the tiny /// residual font_scale that keeps macroquad's rasterizer crisp. fn crisp(target_px: f32) -> (u16, f32) { let n = target_px.round().max(1.0); (n as u16, target_px / n) } /// draw_text_ex at an exact pixel size, using the active font. fn draw_text_px( font: Option<&Font>, txt: &str, x: f32, y: f32, target_px: f32, color: Color, ) { let (n, res) = crisp(target_px); draw_text_ex( txt, x, y, TextParams { font, font_size: n, font_scale: res, font_scale_aspect: 1.0, rotation: 0.0, color, }, ); } /// measure_text matching draw_text_px. fn measure_px(font: Option<&Font>, txt: &str, target_px: f32) -> TextDimensions { let (n, res) = crisp(target_px); measure_text(txt, font, n, res) } /// Draw one character centered at (cx, cy) with a subtle glow. /// /// `rgb` is the glyph's hue, `glow` its luminance (0..~1.4), `scale` its /// size multiplier. One tight halo layer keeps a hint of bloom without the /// stacked-shadow look; the core is pushed toward white as glow rises. #[allow(clippy::too_many_arguments)] fn draw_glyph( txt: &str, cx: f32, cy: f32, scale: f32, glow: f32, rgb: [f32; 3], m: &TextDimensions, deep: bool, font: Option<&Font>, glow_level: usize, ) { // draw_text renders into Rect(x, y - offset_y, width, height); the // metrics were measured at GLYPH_BASE, so scale them by the pixel ratio. let (n, res) = crisp(GLYPH_BASE as f32 * scale); let k = n as f32 / GLYPH_BASE as f32; let x = cx - m.width * k * 0.5; let y = cy + (m.offset_y - m.height * 0.5) * k; if deep { // Single tight halo — a breath of glow, not a shadow stack. let halo_a = HALO_ALPHA[glow_level] * glow; if halo_a > 0.004 { let (hn, hres) = crisp(GLYPH_BASE as f32 * scale * 1.22); let hk = hn as f32 / GLYPH_BASE as f32; draw_text_ex( txt, cx - m.width * hk * 0.5, cy + (m.offset_y - m.height * 0.5) * hk, TextParams { font, font_size: hn, font_scale: hres, font_scale_aspect: 1.0, rotation: 0.0, color: Color::new(rgb[0], rgb[1], rgb[2], halo_a), }, ); } } let lift = CORE_LIFT[glow_level] * glow; let core_a = (0.55 + 0.45 * glow).min(1.0); let core = Color::new( (rgb[0] + (1.0 - rgb[0]) * lift).min(1.0), (rgb[1] + (1.0 - rgb[1]) * lift).min(1.0), (rgb[2] + (1.0 - rgb[2]) * lift).min(1.0), core_a, ); draw_text_ex( txt, x, y, TextParams { font, font_size: n, font_scale: res, font_scale_aspect: 1.0, rotation: 0.0, color: core, }, ); } #[allow(clippy::too_many_arguments)] fn dashed_rect(x0: f32, y0: f32, x1: f32, y1: f32, dash: f32, gap: f32, thick: f32, color: Color) { let mut x = x0; while x < x1 { let xe = (x + dash).min(x1); draw_line(x, y0, xe, y0, thick, color); draw_line(x, y1, xe, y1, thick, color); x = xe + gap; } let mut y = y0; while y < y1 { let ye = (y + dash).min(y1); draw_line(x0, y, x0, ye, thick, color); draw_line(x1, y, x1, ye, thick, color); y = ye + gap; } } // --------------------------------------------------------------------------- // Rendering // --------------------------------------------------------------------------- fn render(game: &Game, m: &HashMap) { let (sw, sh) = (screen_width(), screen_height()); let (cw, chh) = game.cell_size(); // Settling freezes every sine on the sheet — the numbers hold still. let t = if game.settling() { 1_000.0 } else { game.tick }; clear_background(Color::new(0.030, 0.040, 0.058, 1.0)); // Visible cell range. let c0 = ((game.cam.0 / cw).floor() as i32 - 1).max(0); let r0 = ((game.cam.1 / chh).floor() as i32 - 1).max(0); let c1 = (((game.cam.0 + sw) / cw).ceil() as i32 + 1).min(WORLD_COLS - 1); let r1 = (((game.cam.1 + sh) / chh).ceil() as i32 + 1).min(WORLD_ROWS - 1); let pop = if t - game.pop_at < 0.7 { 1.0 - (t - game.pop_at) / 0.7 } else { 0.0 }; let font = game.active_font(); let glow_level = game.settings.glow; // --- glyphs ----------------------------------------------------------- for r in r0..=r1 { for c in c0..=c1 { let ch = game.char_at(r, c); if ch == ' ' { continue; } let cx = c as f32 * cw - game.cam.0 + cw * 0.5; let cy = r as f32 * chh - game.cam.1 + chh * 0.5; let phase = phase_of(r, c); // Which box owns this cell, if any? let mut rgb = FIELD_RGB; // Region fill keeps the box hue even on suspect cells. let mut fill_rgb = FIELD_RGB; let mut boxed = false; let mut selected = false; let mut refined = false; for b in &game.boxes { if b.cells.contains(&(r, c)) { boxed = true; rgb = b.color; fill_rgb = b.color; refined = b.refined; if game.selected_id == Some(b.id) { selected = true; } } } let (mut scale, mut glow, deep); if selected { // The selection breathes hardest: big slow growth + full glow. let v = (t * 2.0 + phase).sin() * 0.5 + 0.5; scale = 0.92 + 0.50 * v; glow = 0.85 + 0.25 * v; rgb = SELECT_RGB; deep = true; if pop > 0.0 { scale *= 1.0 + 0.45 * pop; glow = (glow * (1.0 + 0.6 * pop)).min(1.5); } } else if boxed && refined { // Refined numbers are spent: calm, dim, in their box's hue. let v = (t * 0.4 + phase).sin() * 0.5 + 0.5; scale = 0.93 + 0.05 * v; glow = 0.16 + 0.06 * v; deep = false; } else if boxed { let v = (t * 1.15 + phase).sin() * 0.5 + 0.5; scale = 0.88 + 0.34 * v; glow = 0.55 + 0.40 * v; deep = true; } else if game.suspect_at(r, c) { // Suspect numbers flicker like a failing bulb. let p = (t * 2.6 + phase * 2.0).sin() * 0.5 + 0.5; let p = p * p; // sharpen scale = 0.97 + 0.24 * p; glow = 0.35 + 0.75 * p; rgb = SUSPECT_RGB; deep = true; } else { // Idle field: a barely-perceptible breath, no flashing. let v = (t * 0.45 + phase).sin() * 0.5 + 0.5; scale = 0.96 + 0.06 * v; glow = 0.22 + 0.10 * v; deep = false; } // Suspect yellows keep their hue even inside a box — boxing a // suspect number must not repaint it in the box's color. if boxed && game.suspect_at(r, c) { rgb = SUSPECT_RGB; } // Trail: hidden siblings of a freshly refined temper pulse on // the sheet — a map to follow, never a translation. if let Some(tt) = game.trail_temper && game.tick < game.trail_until && game.temper_at(r, c) == tt { let pulse = (game.tick * 3.0 + phase).sin() * 0.5 + 0.5; glow = (glow + 0.22 * pulse).min(1.5); scale += 0.06 * pulse; } // Wiggle: boxed numbers drift sideways mid-cycle (refined stay still). let mut dx = 0.0; if boxed && !refined { let amp = if selected { 2.4 } else { 1.1 }; dx = (t * 5.0 + phase * 1.7).sin() * amp; } // Soft region fill so boxed areas read as one lit surface. if boxed { let a = if selected { 0.055 } else if refined { 0.018 } else { 0.035 }; draw_rectangle( c as f32 * cw - game.cam.0, r as f32 * chh - game.cam.1, cw, chh, Color::new(fill_rgb[0], fill_rgb[1], fill_rgb[2], a), ); } let txt = ch.to_string(); let metrics = m.get(&ch).copied().unwrap_or(TextDimensions { width: GLYPH_BASE as f32 * 0.6, height: GLYPH_BASE as f32, offset_y: GLYPH_BASE as f32 * 0.8, }); draw_glyph( &txt, cx + dx, cy, scale, glow, rgb, &metrics, deep, font, glow_level, ); } } // --- box borders ------------------------------------------------------ for b in &game.boxes { let is_sel = game.selected_id == Some(b.id); let pulse = (t * 6.0).sin() * 0.5 + 0.5; let rgb = if is_sel { // Selection border breathes between its hue and white. [ b.color[0] + (1.0 - b.color[0]) * 0.55 * pulse, b.color[1] + (1.0 - b.color[1]) * 0.55 * pulse, b.color[2] + (1.0 - b.color[2]) * 0.55 * pulse, ] } else { b.color }; // Refined boxes settle into a quiet, dimmed outline. let (core_th, glow_th, core_a, glow_a) = if is_sel { ((2.6 + 1.3 * pulse) * game.zoom, (10.0 + 5.0 * pulse) * game.zoom, 0.92, 0.10) } else if b.refined { (1.6 * game.zoom, 5.0 * game.zoom, 0.38, 0.04) } else { (2.4 * game.zoom, 8.5 * game.zoom, 0.92, 0.10) }; for &(r, c) in &b.cells { // Skip cells far off-screen. let sx = c as f32 * cw - game.cam.0; let sy = r as f32 * chh - game.cam.1; if sx + cw < -20.0 || sy + chh < -20.0 || sx > sw + 20.0 || sy > sh + 20.0 { continue; } let x1 = sx + cw; let y1 = sy + chh; let edge = |ax: f32, ay: f32, bx: f32, by: f32| { draw_line(ax, ay, bx, by, glow_th, Color::new(rgb[0], rgb[1], rgb[2], glow_a)); draw_line(ax, ay, bx, by, core_th, Color::new(rgb[0], rgb[1], rgb[2], core_a)); }; if !b.cells.contains(&(r - 1, c)) { edge(sx, sy, x1, sy); } if !b.cells.contains(&(r + 1, c)) { edge(sx, y1, x1, y1); } if !b.cells.contains(&(r, c - 1)) { edge(sx, sy, sx, y1); } if !b.cells.contains(&(r, c + 1)) { edge(x1, sy, x1, y1); } } // Selected boxes get an inner echo border. if is_sel { let inset = 5.0 * game.zoom; for &(r, c) in &b.cells { let sx = c as f32 * cw - game.cam.0 + inset; let sy = r as f32 * chh - game.cam.1 + inset; let x1 = sx + cw - inset * 2.0; let y1 = sy + chh - inset * 2.0; if x1 < sx || y1 < sy { continue; } if x1 < -20.0 || y1 < -20.0 || sx > sw + 20.0 || sy > sh + 20.0 { continue; } let a = 0.22 + 0.18 * pulse; let col = Color::new(rgb[0], rgb[1], rgb[2], a); let th = 1.4 * game.zoom; if !b.cells.contains(&(r - 1, c)) { draw_line(sx, sy, x1, sy, th, col); } if !b.cells.contains(&(r + 1, c)) { draw_line(sx, y1, x1, y1, th, col); } if !b.cells.contains(&(r, c - 1)) { draw_line(sx, sy, sx, y1, th, col); } if !b.cells.contains(&(r, c + 1)) { draw_line(x1, sy, x1, y1, th, col); } } } } // --- refined stamps ---------------------------------------------------- for b in &game.boxes { if !b.refined { continue; } let (mut rmin, mut rmax, mut cmin, mut cmax) = (i32::MAX, i32::MIN, i32::MAX, i32::MIN); for &(r, c) in &b.cells { rmin = rmin.min(r); rmax = rmax.max(r); cmin = cmin.min(c); cmax = cmax.max(c); } let sx = cmin as f32 * cw - game.cam.0; let sy = rmin as f32 * chh - game.cam.1; if sx > sw || sy > sh || sx + cw < 0.0 || sy + chh < 0.0 { continue; } // Small "R" pill at the box's top-left corner. let pw = 17.0 * game.zoom.max(0.8); let ph = 15.0 * game.zoom.max(0.8); let (hx, hy, hw, hh) = (sx + 2.0, sy + 2.0, pw, ph); draw_rectangle( hx, hy, hw, hh, Color::new(b.color[0], b.color[1], b.color[2], 0.90), ); draw_rectangle_lines(hx, hy, hw, hh, 1.2, Color::new(0.02, 0.03, 0.05, 0.9)); let rm = measure_px(font, "R", GLYPH_BASE as f32 * 0.55); draw_text_px( font, "R", hx + (hw - rm.width) * 0.5, hy + (hh + rm.height * 0.5 - rm.offset_y) * 0.5, GLYPH_BASE as f32 * 0.55, Color::new(0.03, 0.05, 0.08, 1.0), ); } // --- refinement output panel (right side) ------------------------------ { let panel_w = 460.0f32; let margin = 14.0f32; let px0 = sw - panel_w - margin; let py0 = margin; let py1 = sh - 78.0 - margin; let ph = py1 - py0; draw_rectangle(px0, py0, panel_w, ph, Color::new(0.012, 0.020, 0.034, 0.88)); draw_rectangle_lines(px0, py0, panel_w, ph, 1.5, Color::new(0.25, 0.70, 0.80, 0.55)); let tx = px0 + 18.0; draw_text_px( font, "REFINEMENT OUTPUT", tx, py0 + 20.0, GLYPH_BASE as f32 * 1.05, Color::new(0.35, 0.85, 0.95, 1.0), ); draw_text_px( font, "space grabs the selection", tx, py0 + 48.0, GLYPH_BASE as f32 * 0.74, Color::new(0.40, 0.50, 0.60, 1.0), ); // --- quota bar ---------------------------------------------------- let qtxt = format!( "SHIFT {} QUOTA {} / {}", game.shift_no, game.shift_mass, game.quota_target ); draw_text_px( font, &qtxt, tx, py0 + 72.0, GLYPH_BASE as f32 * 0.72, Color::new(0.70, 0.78, 0.86, 1.0), ); let bar_x = tx - 4.0; let bar_y = py0 + 94.0; let bar_w = panel_w - 28.0; let bar_h = 14.0f32; draw_rectangle(bar_x, bar_y, bar_w, bar_h, Color::new(0.05, 0.08, 0.12, 1.0)); let frac = (game.shift_mass as f32 / game.quota_target as f32).clamp(0.0, 1.0); let full = frac >= 1.0; let pulse = (t * 4.0).sin() * 0.5 + 0.5; let fill_c = if full { Color::new(1.0, 0.92, 0.45, 0.75 + 0.25 * pulse) } else { Color::new(0.30, 0.80, 0.90, 0.85) }; if frac > 0.0 { draw_rectangle(bar_x, bar_y, bar_w * frac, bar_h, fill_c); } draw_rectangle_lines(bar_x, bar_y, bar_w, bar_h, 1.2, Color::new(0.25, 0.55, 0.65, 0.70)); if game.refinements.is_empty() { draw_text_px( font, "no refinements yet.", tx, py0 + 140.0, GLYPH_BASE as f32 * 0.82, Color::new(0.45, 0.52, 0.60, 1.0), ); draw_text_px( font, "box some numbers, then", tx, py0 + 170.0, GLYPH_BASE as f32 * 0.82, Color::new(0.45, 0.52, 0.60, 1.0), ); draw_text_px( font, "press space to refine.", tx, py0 + 200.0, GLYPH_BASE as f32 * 0.82, Color::new(0.45, 0.52, 0.60, 1.0), ); } // --- output entries with sigils ------------------------------------ let mut ey = py0 + 124.0; for rf in &game.refinements { // Slide-in + flash for freshly filed entries. let age = t - rf.at; let anim = (age / 0.28).clamp(0.0, 1.0); let ease = 1.0 - (1.0 - anim).powi(3); let dx = -(1.0 - ease) * 70.0; let flash = if age < 0.45 { 1.0 - age / 0.45 } else { 0.0 }; let eh = 78.0f32; if ey + eh > py1 - 6.0 { break; } let ex = tx + dx; // Flash bed for new entries. if flash > 0.0 { draw_rectangle( tx - 10.0, ey - 6.0, panel_w - 20.0, eh - 6.0, Color::new(0.9, 0.95, 1.0, 0.10 * flash), ); } // Left accent bar in the source box's hue. draw_rectangle( tx - 10.0, ey - 6.0, 4.0, eh - 12.0, Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.85), ); // Sigil: the silhouette of the region you refined. let sig_px = 46.0f32; let s = sig_px / rf.sigil_n as f32; let sx0 = ex; let sy0 = ey - 2.0; draw_rectangle( sx0 - 1.0, sy0 - 1.0, sig_px + 2.0, sig_px + 2.0, Color::new(0.04, 0.07, 0.10, 0.9), ); for sr in 0..rf.sigil_n { for sc in 0..rf.sigil_n { if rf.sigil[sr * rf.sigil_n + sc] { draw_rectangle( sx0 + sc as f32 * s, sy0 + sr as f32 * s, s - 0.6, s - 0.6, Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.92), ); } } } draw_rectangle_lines( sx0 - 1.0, sy0 - 1.0, sig_px + 2.0, sig_px + 2.0, 1.0, Color::new(rf.hue[0], rf.hue[1], rf.hue[2], 0.45), ); let tc = ex + 60.0; // text column, right of the sigil let mc = mood_color(rf.mood); let l1 = format!("#{:03} {} {:.1}%", rf.index, rf.mood, rf.score); draw_text_px( font, &l1, tc, ey, GLYPH_BASE as f32 * 0.92, Color::new(0.90, 0.94, 1.00, 1.0), ); // Recolor just the mood word: draw mood in its temper color on top. let head = format!("#{:03} ", rf.index); let hm = measure_px(font, &head, GLYPH_BASE as f32 * 0.92); draw_text_px( font, rf.mood, tc + hm.width, ey, GLYPH_BASE as f32 * 0.92, Color::new(mc[0], mc[1], mc[2], 1.0), ); let l2 = format!( "Σ {} · {}d · {}l · dom '{}'", rf.digit_sum, rf.digit_count, rf.letter_count, rf.dominant ); draw_text_px( font, &l2, tc, ey + 27.0, GLYPH_BASE as f32 * 0.70, Color::new(0.55, 0.63, 0.72, 1.0), ); draw_text_px( font, rf.line, tc, ey + 51.0, GLYPH_BASE as f32 * 0.62, Color::new(0.48, 0.55, 0.64, 1.0), ); ey += eh; } } // --- in-progress selection preview ------------------------------------ let preview: Option<(Cell, Cell)> = if game.cursor_mode { game.anchor.map(|a| (a, game.cursor)) } else { game.left_drag }; if let Some((a, b)) = preview { let (r0, r1) = (a.0.min(b.0), a.0.max(b.0)); let (c0, c1) = (a.1.min(b.1), a.1.max(b.1)); let x0 = c0 as f32 * cw - game.cam.0; let y0 = r0 as f32 * chh - game.cam.1; let x1 = (c1 + 1) as f32 * cw - game.cam.0; let y1 = (r1 + 1) as f32 * chh - game.cam.1; draw_rectangle( x0, y0, x1 - x0, y1 - y0, Color::new(1.0, 0.92, 0.45, 0.06), ); let pc = Color::new(1.0, 0.92, 0.45, 0.85); dashed_rect(x0, y0, x1, y1, 9.0, 7.0, 2.2, pc); } // --- keyboard box-cursor crosshair ------------------------------------ if game.cursor_mode { let cx = game.cursor.1 as f32 * cw - game.cam.0 + cw * 0.5; let cy = game.cursor.0 as f32 * chh - game.cam.1 + chh * 0.5; let len = 13.0 * game.zoom.max(0.8); let th = 2.4 * game.zoom.max(0.8); let col = Color::new(0.30, 0.90, 1.00, 0.95); let glow_col = Color::new(0.30, 0.90, 1.00, 0.18); draw_line(cx - len, cy, cx + len, cy, th * 3.0, glow_col); draw_line(cx, cy - len, cx, cy + len, th * 3.0, glow_col); draw_line(cx - len, cy, cx + len, cy, th, col); draw_line(cx, cy - len, cx, cy + len, th, col); } // --- CRT scanlines (subtle, toggleable) -------------------------------- if game.settings.scanlines { let mut y = 0.0; while y < sh { draw_rectangle(0.0, y, sw, 1.0, Color::new(0.0, 0.0, 0.0, 0.045)); y += 3.0; } } // --- HUD -------------------------------------------------------------- draw_rectangle(0.0, sh - 78.0, sw, 78.0, Color::new(0.010, 0.016, 0.028, 0.88)); let mode_tag = if game.cursor_mode { " MODE: BOX-CURSOR" } else { "" }; let title = "LUMON // MACRODATA REFINE"; let fs1 = GLYPH_BASE as f32 * 0.82; let tm = measure_px(font, title, fs1); draw_text_px( font, title, 16.0, sh - 78.0 + 28.0, fs1, Color::new(0.35, 0.85, 0.95, 1.0), ); let cell_x = (game.cam.0 / cw).floor() as i32; let cell_y = (game.cam.1 / chh).floor() as i32; let stats = format!( "SHIFT {} BOXES: {} ENCLOSED: {} REFINED: {} VIEW: ({}, {}) ZOOM: {:.2}x{}", game.shift_no, game.boxes.len(), game.enclosed_total(), game.next_refine - 1, cell_x, cell_y, game.zoom, mode_tag, ); let stats_x = 16.0 + tm.width + 28.0; draw_text_px( font, &stats, stats_x, sh - 78.0 + 28.0, fs1, Color::new(0.55, 0.65, 0.75, 1.0), ); if t - game.status_at < 2.4 && !game.status.is_empty() { let sm = measure_px(font, &stats, fs1); draw_text_px( font, &format!(" >> {}", game.status), stats_x + sm.width + 24.0, sh - 78.0 + 28.0, fs1, Color::new(1.0, 0.85, 0.30, 1.0), ); } let hint = "L-drag: draw R-drag: pan wheel: zoom space: refine b: box-cursor ,: settings z: undo c: clear q: quit"; draw_text_px( font, hint, 16.0, sh - 78.0 + 58.0, GLYPH_BASE as f32 * 0.68, Color::new(0.42, 0.50, 0.60, 1.0), ); // --- shift-complete settle overlay ------------------------------------- if game.settling() { let prog = ((game.tick - (game.settle_until - SETTLE_SECS)) / SETTLE_SECS).clamp(0.0, 1.0); draw_rectangle( 0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.35 + 0.30 * prog), ); let big = GLYPH_BASE as f32 * 2.4; let m1 = measure_px(font, "SHIFT COMPLETE", big); let y1 = sh * 0.5 - 46.0 + m1.offset_y; draw_text_px( font, "SHIFT COMPLETE", (sw - m1.width) * 0.5, y1, big, Color::new(0.35, 0.85, 0.95, 1.0), ); let sub = format!( "quota {}/{} met · sheet {} incoming", game.shift_mass, game.quota_target, game.shift_no + 1 ); let fs2 = GLYPH_BASE as f32 * 0.95; let m2 = measure_px(font, &sub, fs2); let y2 = sh * 0.5 + 16.0 + m2.offset_y; draw_text_px( font, &sub, (sw - m2.width) * 0.5, y2, fs2, Color::new(0.55, 0.65, 0.75, 1.0), ); } // --- settings panel ---------------------------------------------------- if game.settings_open { render_settings(game, font, sw, sh); } } /// Modal settings panel rect (x, y, w, h) — single source of truth so the /// render and the input hit-tests can never drift apart. fn settings_panel(sw: f32, sh: f32) -> (f32, f32, f32, f32) { let w = 560.0f32; let h = 400.0f32; ( (sw - w) * 0.5, (sh - h) * 0.5, w, h, ) } /// Geometry of the four settings rows inside the panel. /// Returns (x, y, w, h) per row: 0 font, 1 glow, 2 scanlines, 3 sound. fn settings_rows(sw: f32, sh: f32) -> [(f32, f32, f32, f32); 4] { let (px, py, pw, _ph) = settings_panel(sw, sh); let row_w = pw - 48.0; let row_x = px + 24.0; let row_h = 48.0f32; let top = py + 78.0; let row = |i: usize| (row_x, top + i as f32 * (row_h + 10.0), row_w, row_h); [row(0), row(1), row(2), row(3)] } /// Draw text vertically centered inside a rect, left- or right-aligned. /// /// macroquad anchors `y` at the baseline (text top = y - offset_y), so the /// rect center is converted to a baseline using the measured metrics — this /// keeps every label optically centered in its row for any font. fn panel_text( font: Option<&Font>, txt: &str, rect: (f32, f32, f32, f32), target_px: f32, color: Color, right: bool, ) { let (rx, ry, rw, rh) = rect; let m = measure_px(font, txt, target_px); let y = ry + (rh - m.height) * 0.5 + m.offset_y; let x = if right { (rx + rw - m.width - 18.0).max(rx + 150.0) } else { rx + 18.0 }; draw_text_px(font, txt, x, y, target_px, color); } fn render_settings(game: &Game, font: Option<&Font>, sw: f32, sh: f32) { let (px, py, pw, ph) = settings_panel(sw, sh); // Dim the scene behind the panel. draw_rectangle(0.0, 0.0, sw, sh, Color::new(0.0, 0.0, 0.0, 0.45)); draw_rectangle(px, py, pw, ph, Color::new(0.012, 0.020, 0.034, 0.97)); draw_rectangle_lines(px, py, pw, ph, 2.0, Color::new(0.35, 0.85, 0.95, 0.80)); // Title centered in its header band. panel_text( font, "SETTINGS", (px, py + 10.0, pw, 48.0), GLYPH_BASE as f32 * 1.15, Color::new(0.35, 0.85, 0.95, 1.0), false, ); let rows = settings_rows(sw, sh); let labels = ["FONT", "GLOW", "SCANLINES", "SOUND"]; let values = [ FONT_NAMES[game.settings.font].to_string(), GLOW_NAMES[game.settings.glow].to_string(), if game.settings.scanlines { "on".to_string() } else { "off".to_string() }, if game.settings.sound { "on".to_string() } else { "off".to_string() }, ]; for i in 0..4 { let rect = rows[i]; let (rx, ry, rw, rh) = rect; let focused = game.settings_focus == i; if focused { draw_rectangle(rx, ry, rw, rh, Color::new(0.30, 0.75, 0.85, 0.16)); draw_rectangle_lines(rx, ry, rw, rh, 1.5, Color::new(0.35, 0.85, 0.95, 0.75)); } let label_c = if focused { Color::new(0.90, 0.96, 1.00, 1.0) } else { Color::new(0.55, 0.63, 0.72, 1.0) }; let value_c = if focused { Color::new(1.0, 0.85, 0.30, 1.0) } else { Color::new(0.70, 0.78, 0.86, 1.0) }; panel_text(font, labels[i], rect, GLYPH_BASE as f32 * 0.85, label_c, false); // Value with arrows as a change affordance when focused. let vtxt = if focused { format!("< {} >", values[i]) } else { values[i].clone() }; panel_text(font, &vtxt, rect, GLYPH_BASE as f32 * 0.85, value_c, true); } // Footers inside the panel, spaced by measured height. panel_text( font, "click a row or arrows to change · , or esc closes", (px, py + ph - 60.0, pw, 22.0), GLYPH_BASE as f32 * 0.66, Color::new(0.42, 0.50, 0.60, 1.0), false, ); panel_text( font, "settings are saved automatically", (px, py + ph - 32.0, pw, 20.0), GLYPH_BASE as f32 * 0.60, Color::new(0.35, 0.42, 0.50, 1.0), false, ); } // --------------------------------------------------------------------------- // Main loop // --------------------------------------------------------------------------- async fn run() { // Embedded TTF fonts (index 0 in the picker is the built-in bitmap). let ttf: [&[u8]; 4] = [ include_bytes!("../assets/fonts/JetBrainsMono-Regular.ttf"), include_bytes!("../assets/fonts/SpaceMono-Regular.ttf"), include_bytes!("../assets/fonts/IBMPlexMono-Regular.ttf"), include_bytes!("../assets/fonts/FiraMono-Regular.ttf"), ]; let mut fonts: Vec> = vec![None]; for bytes in ttf { fonts.push(load_ttf_font_from_bytes(bytes).ok()); } let mut game = Game::new(fonts); game.sfx = Sfx::load(game.settings.sound).await; // Glyph metrics cache (one entry per distinct character), rebuilt when // the active font changes. let charset: Vec = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect(); let build_metrics = |font: Option<&Font>| -> HashMap { charset .iter() .map(|&ch| { let txt = ch.to_string(); (ch, measure_text(&txt, font, GLYPH_BASE, 1.0)) }) .collect() }; let mut metrics = build_metrics(game.active_font()); let mut metrics_font = game.settings.font; loop { game.tick = get_time() as f32; game.frames += 1; let (sw, sh) = (screen_width(), screen_height()); let (mx, my) = mouse_position(); if game.settings.font != metrics_font { metrics = build_metrics(game.active_font()); metrics_font = game.settings.font; } // Quit works in every phase. if is_key_pressed(KeyCode::Q) { break; } // --- settings panel (blocks game input while open) ---------------- if game.settings_open { if is_key_pressed(KeyCode::Comma) || is_key_pressed(KeyCode::Escape) { game.settings_open = false; game.settings.save(); } if is_key_pressed(KeyCode::Up) { game.settings_focus = (game.settings_focus + 3) % 4; } if is_key_pressed(KeyCode::Down) { game.settings_focus = (game.settings_focus + 1) % 4; } let mut changed = false; if is_key_pressed(KeyCode::Left) { cycle_setting(&mut game, -1); changed = true; } if is_key_pressed(KeyCode::Right) || is_key_pressed(KeyCode::Enter) { cycle_setting(&mut game, 1); changed = true; } if is_mouse_button_pressed(MouseButton::Left) { for (i, (rx, ry, rw, rh)) in settings_rows(sw, sh).into_iter().enumerate() { if mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh { game.settings_focus = i; cycle_setting(&mut game, 1); changed = true; } } } if changed { game.settings.save(); } } else if game.phase == Phase::Work { // --- zoom (scroll wheel, anchored at the cursor) ------------------ let (_, wheel) = mouse_wheel(); // Sanity guard: real notches are ~±1; ignore noisy environments. if wheel.abs() > 0.01 && wheel.abs() < 4.0 && !game.right_drag { let old = game.zoom; game.zoom = (game.zoom * (1.0 + wheel * 0.10)).clamp(ZOOM_MIN, ZOOM_MAX); let wu = ((mx + game.cam.0) / old, (my + game.cam.1) / old); game.cam = (wu.0 * game.zoom - mx, wu.1 * game.zoom - my); } // --- right-drag pan ------------------------------------------------ if is_mouse_button_pressed(MouseButton::Right) { game.right_drag = true; game.last_mouse = (mx, my); } if game.right_drag && is_mouse_button_down(MouseButton::Right) { game.cam.0 -= mx - game.last_mouse.0; game.cam.1 -= my - game.last_mouse.1; game.last_mouse = (mx, my); } if is_mouse_button_released(MouseButton::Right) { game.right_drag = false; } // --- left selection (polled — no reliance on drag-motion events) -- if !game.cursor_mode { let cell_here = game.cell_at(mx, my); let down = is_mouse_button_down(MouseButton::Left); let released = is_mouse_button_released(MouseButton::Left); if is_mouse_button_pressed(MouseButton::Left) { game.left_drag = Some((cell_here, cell_here)); } if down && let Some(slot) = &mut game.left_drag { slot.1 = cell_here; } if released && let Some((a, _)) = game.left_drag.take() { // The release position always defines the far corner. game.commit_box(a, cell_here); } // The preview may only exist while the button is physically held. // WM focus quirks can replay presses without releases; anything // not backed by a real held button is dropped. if !down { game.left_drag = None; } } // --- keyboard ------------------------------------------------------ if is_key_pressed(KeyCode::Escape) { if game.cursor_mode { game.cursor_mode = false; game.anchor = None; game.left_drag = None; game.flash("normal mode"); } else { break; } } if is_key_pressed(KeyCode::Z) { game.undo_last(); } if is_key_pressed(KeyCode::C) { game.clear_boxes(); } if is_key_pressed(KeyCode::Space) && !game.cursor_mode { // Grab the selection: file it as refinement output. game.grab_selection(); } if is_key_pressed(KeyCode::B) { game.cursor_mode = !game.cursor_mode; game.anchor = None; game.left_drag = None; if game.cursor_mode { game.cursor = game.cell_at(sw * 0.5, sh * 0.5); game.flash("box cursor: arrows move, space anchors, enter draws, esc exits"); } } if is_key_pressed(KeyCode::Home) { game.cam = (0.0, 0.0); } let big = is_key_down(KeyCode::LeftShift) || is_key_down(KeyCode::RightShift); if game.cursor_mode { // Cursor movement in cells. let cs = if big { 4 } else { 1 }; let mut moved = false; if is_key_pressed(KeyCode::Left) || is_key_pressed(KeyCode::A) { game.cursor.1 -= cs; moved = true; } if is_key_pressed(KeyCode::Right) || is_key_pressed(KeyCode::D) { game.cursor.1 += cs; moved = true; } if is_key_pressed(KeyCode::Up) || is_key_pressed(KeyCode::W) { game.cursor.0 -= cs; moved = true; } if is_key_pressed(KeyCode::Down) || is_key_pressed(KeyCode::S) { game.cursor.0 += cs; moved = true; } if moved { game.cursor = ( game.cursor.0.clamp(0, WORLD_ROWS - 1), game.cursor.1.clamp(0, WORLD_COLS - 1), ); if let Some(a) = game.anchor { game.left_drag = Some((a, game.cursor)); } } if is_key_pressed(KeyCode::Space) { if game.anchor == Some(game.cursor) { game.anchor = None; game.left_drag = None; game.flash("anchor cleared"); } else { game.anchor = Some(game.cursor); game.left_drag = Some((game.cursor, game.cursor)); game.flash("anchor set - move, enter draws"); } } if is_key_pressed(KeyCode::Enter) { if let Some(a) = game.anchor.take() { game.left_drag = None; game.commit_box(a, game.cursor); } else { game.flash("space first to anchor"); } } } else { // Camera pan in pixels (free, not grid-snapped). let step = if big { 240.0 } else { 60.0 }; if is_key_down(KeyCode::Left) || is_key_down(KeyCode::A) { game.cam.0 -= step; } if is_key_down(KeyCode::Right) || is_key_down(KeyCode::D) { game.cam.0 += step; } if is_key_down(KeyCode::Up) || is_key_down(KeyCode::W) { game.cam.1 -= step; } if is_key_down(KeyCode::Down) || is_key_down(KeyCode::S) { game.cam.1 += step; } } if is_key_pressed(KeyCode::Comma) && game.phase == Phase::Work { game.settings_open = true; game.settings_focus = 0; } } // end input blocked while settings open // Quota met: the settle finishes, a fresh sheet loads. if game.phase == Phase::Settling && game.tick >= game.settle_until { game.next_shift(); } game.clamp_cam(sw, sh); render(&game, &metrics); // Headless smoke test: run a few seconds of frames, then exit cleanly. if std::env::var("LUMON_SMOKE").is_ok() && game.frames > 150 { break; } next_frame().await; } game.settings.save(); } /// Cycle the focused setting. `dir` is -1 or +1. fn cycle_setting(game: &mut Game, dir: i32) { match game.settings_focus { 0 => { let n = FONT_NAMES.len() as i32; game.settings.font = ((game.settings.font as i32 + dir).rem_euclid(n)) as usize; } 1 => { let n = GLOW_NAMES.len() as i32; game.settings.glow = ((game.settings.glow as i32 + dir).rem_euclid(n)) as usize; } 2 => game.settings.scanlines = !game.settings.scanlines, _ => { game.settings.sound = !game.settings.sound; game.sfx.enabled = game.settings.sound; } } // A tick confirms every change — including re-enabling sound, which you // should hear immediately. game.sfx.play(SfxId::Tick, 0.6); } fn window_conf() -> Conf { Conf { window_title: "Lumon — Macrodata Refine".to_owned(), window_width: 1280, window_height: 800, high_dpi: true, sample_count: 4, ..Default::default() } } #[macroquad::main(window_conf)] async fn main() { run().await; } // --------------------------------------------------------------------------- // Headless logic tests — no window, no display. // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; fn game() -> Game { Game::new(vec![None]) } #[test] fn field_is_deterministic_and_tagged() { let (a1, s1, t1) = generate_field(BASE_SEED); let (a2, s2, t2) = generate_field(BASE_SEED); assert_eq!(a1, a2); assert_eq!(s1, s2); assert_eq!(t1, t2); assert!( t1.iter().any(|&t| t != TEMPER_NONE), "field should contain temper clusters" ); let (a3, _, _) = generate_field(BASE_SEED ^ 1); assert_ne!(a1, a3, "a different seed must produce a different sheet"); } #[test] fn components_split_disconnected_sets() { let mut set = HashSet::new(); set.insert((0, 0)); set.insert((0, 1)); set.insert((5, 5)); assert_eq!(connected_components(&set).len(), 2); } #[test] fn quota_grows_each_shift() { assert_eq!(quota_target_for(1), QUOTA_BASE); assert_eq!(quota_target_for(2), QUOTA_BASE + QUOTA_STEP); } #[test] fn carve_removes_only_subset_and_splits() { let mut g = game(); g.commit_box((2, 2), (5, 5)); assert_eq!(g.boxes.len(), 1); assert_eq!(g.boxes[0].cells.len(), 16); // Carve the middle column: the 4x4 splits into two strips. g.commit_box((2, 3), (5, 3)); assert_eq!(g.boxes.len(), 2, "carve should split the box in two"); let total: usize = g.boxes.iter().map(|b| b.cells.len()).sum(); assert_eq!(total, 12, "only the carved column should be gone"); assert!(g.boxes.iter().any(|b| b.cells.contains(&(2, 2)))); assert!(!g.boxes.iter().any(|b| b.cells.contains(&(2, 3)))); } #[test] fn exact_reselect_toggles_off() { let mut g = game(); g.commit_box((1, 1), (3, 4)); assert_eq!(g.boxes.len(), 1); g.commit_box((1, 1), (3, 4)); assert!(g.boxes.is_empty()); } #[test] fn refine_reads_temper_and_feeds_quota() { let mut g = game(); // Find a 2x2 block tagged with a single temper. let mut found: Option<(Cell, u8)> = None; 'outer: for r in 0..WORLD_ROWS - 2 { for c in 0..WORLD_COLS - 2 { let t = g.temper_at(r, c); if t != TEMPER_NONE && g.temper_at(r, c + 1) == t && g.temper_at(r + 1, c) == t && g.temper_at(r + 1, c + 1) == t { found = Some(((r, c), t)); break 'outer; } } } let ((r, c), t) = found.expect("field should contain temper clusters"); g.commit_box((r, c), (r + 1, c + 1)); g.grab_selection(); assert_eq!(g.refinements.len(), 1); let rf = &g.refinements[0]; assert_eq!( rf.mood, TEMPERS[t as usize].0, "mood must come from the hidden tag, not the hash" ); assert!(g.trail_temper.is_some(), "temper hit should start a trail"); assert!(g.shift_mass > 0, "refining should feed the quota"); assert_eq!(rf.sigil.len(), SIGIL_N * SIGIL_N); } #[test] fn quota_met_settles_shift_and_reloads() { let mut g = game(); g.quota_target = 1; // tiny quota for the test g.commit_box((0, 0), (1, 1)); g.grab_selection(); assert_eq!(g.phase, Phase::Settling); g.next_shift(); assert_eq!(g.phase, Phase::Work); assert_eq!(g.shift_no, 2); assert_eq!(g.shift_mass, 0); assert!(g.boxes.is_empty(), "a new sheet starts empty"); } #[test] fn sigil_captures_shape() { let mut solid = HashSet::new(); for r in 0..4 { for c in 0..4 { solid.insert((r, c)); } } let cells: Vec = solid.iter().copied().collect(); let (bits, n) = sigil_of(&cells); assert_eq!(n, SIGIL_N); assert!(bits.iter().all(|&b| b), "a solid square -> solid sigil"); let mut ell = HashSet::new(); for r in 0..4 { ell.insert((r, 0)); ell.insert((3, r)); } let cells: Vec = ell.iter().copied().collect(); let (bits, _) = sigil_of(&cells); assert!( bits.iter().any(|&b| b) && bits.iter().any(|&b| !b), "an L-shape -> mixed sigil" ); } }