//! 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<Cell>,
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<bool>,
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::<usize>() {
s.font = n.min(FONT_NAMES.len() - 1);
}
}
("glow", v) => {
if let Ok(n) = v.parse::<usize>() {
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<char>,
/// Suspect numbers ("the ones that feel wrong") — they flicker yellow.
suspect: Vec<bool>,
/// Hidden temper tags per cell (TEMPER_NONE = untagged).
temper: Vec<u8>,
boxes: Vec<MergedBox>,
undo: Vec<Vec<MergedBox>>,
next_id: u64,
color_idx: usize,
selected_id: Option<u64>,
/// 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<Cell>,
/// Filed refinement outputs, newest first.
refinements: Vec<Refinement>,
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<u8>,
trail_until: f32,
settings: Settings,
/// Indexed like FONT_NAMES; None = built-in or failed load (fallback).
fonts: Vec<Option<Font>>,
/// 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<char>, Vec<bool>, Vec<u8>) {
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<char> = "0123456789".chars().collect();
let letters: Vec<char> = "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<Cell>, b: &HashSet<Cell>) -> 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<Cell>) -> Vec<HashSet<Cell>> {
let mut seen: HashSet<Cell> = 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<bool>, 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<Cell> = 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<Option<Font>>) -> 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<String>) {
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<Cell> {
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<Cell> = 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<Cell> = 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<Cell> = 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<char, u32> = 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<char, TextDimensions>) {
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<Option<Font>> = 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<char> = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ".chars().collect();
let build_metrics = |font: Option<&Font>| -> HashMap<char, TextDimensions> {
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<Cell> = 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<Cell> = 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"
);
}
}