use std::time::{Duration, SystemTime};
use pixtuoid_core::sprite::{Rgb, RgbBuffer};
use pixtuoid_core::state::FloorLocalDeskIndex;
use crate::tui::layout::Layout;
use crate::tui::pixel_painter::background::{
sun_on_wall, time_of_day_look, weather_light, weather_state, window_spill_columns, WallSide,
};
use crate::tui::pixel_painter::palette::blend_rgb;
use crate::tui::pixel_painter::PixelCtx;
use crate::tui::theme::Theme;
pub(super) struct SunbeamColumn {
pub x: u16,
pub top_y: u16,
pub depth: u16,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(super) struct DustMote {
pub x: u16,
pub y: u16,
pub alpha: f32,
}
const MOTES_PER_COLUMN: usize = 3;
pub(super) fn dust_mote_positions(
floor_seed: u64,
now: SystemTime,
col: &SunbeamColumn,
) -> Vec<DustMote> {
let t_ms = now
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_millis() as u64;
let mut out = Vec::with_capacity(MOTES_PER_COLUMN);
for i in 0..MOTES_PER_COLUMN {
let mut s = floor_seed
.wrapping_add((col.x as u64).wrapping_mul(0xbf58_476d_1ce4_e5b9))
.wrapping_add((i as u64).wrapping_mul(0x94d0_49bb_1331_11eb));
s = (s ^ (s >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
s = (s ^ (s >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
s ^= s >> 31;
let phase = (s % 6283) as f32 / 1000.0;
let speed_y = 0.6 + ((s >> 12) & 0x3) as f32 * 0.2;
let speed_x = 0.4 + ((s >> 14) & 0x3) as f32 * 0.15;
let cycle = col.depth as f32;
let y_offset = ((t_ms as f32 / 1000.0) * speed_y + ((s >> 4) & 0xFF) as f32) % cycle;
let y = col.top_y + y_offset as u16;
let sx = (phase + (t_ms as f32 / 1000.0) * speed_x).sin();
let raw_x = (col.x as f32 + sx * 2.5).round();
let x = raw_x.max(0.0).min(u16::MAX as f32) as u16;
let norm = y_offset / cycle.max(1.0);
let alpha = if norm < 0.15 {
norm / 0.15
} else if norm > 0.85 {
(1.0 - norm) / 0.15
} else {
1.0
};
out.push(DustMote { x, y, alpha });
}
out
}
pub(super) fn paint_ambient(
ctx: &mut PixelCtx<'_>,
seated_agents: &std::collections::HashMap<FloorLocalDeskIndex, bool>,
) {
paint_sun_spot(ctx.buf, ctx.theme, ctx.layout, ctx.now);
paint_dust_motes(
ctx.buf,
ctx.theme,
ctx.layout,
ctx.floor.floor_seed,
ctx.now,
);
let halos = collect_ceiling_halos(ctx, seated_agents);
paint_ceiling_halos(ctx.buf, ctx.theme, &halos);
}
#[derive(Debug, Clone, Copy)]
pub(super) struct CeilingHalo {
pub x: u16,
pub y: u16,
pub color: Rgb,
pub intensity: f32,
}
pub(super) fn paint_ceiling_halos(buf: &mut RgbBuffer, theme: &Theme, halos: &[CeilingHalo]) {
use crate::tui::theme::ThemeKind;
if theme.kind != ThemeKind::Dark {
return;
}
for halo in halos {
for dy in 0..2u16 {
for dx in 0..5u16 {
let x = halo.x.saturating_sub(2).saturating_add(dx);
let y = halo.y.saturating_sub(dy);
if x >= buf.width || y >= buf.height {
continue;
}
let dist = ((dx as i32 - 2).abs() as f32 + dy as f32) / 3.0;
let strength = (halo.intensity * (1.0 - dist).max(0.0) * 0.4).clamp(0.0, 1.0);
let cur = buf.get(x, y);
buf.put(x, y, blend_rgb(cur, halo.color, strength));
}
}
}
}
fn collect_ceiling_halos(
ctx: &PixelCtx<'_>,
seated_agents: &std::collections::HashMap<FloorLocalDeskIndex, bool>,
) -> Vec<CeilingHalo> {
use pixtuoid_core::state::ActivityState;
let mut halos = Vec::new();
for agent in ctx.scene.agents.values() {
if !matches!(
agent.state,
ActivityState::Active {
detail: Some(_),
..
}
) {
continue;
}
if agent.exiting_at.is_some() {
continue;
}
if agent.floor_idx != ctx.floor.floor_idx {
continue;
}
if !seated_agents
.get(&agent.desk_index.single_floor_local())
.copied()
.unwrap_or(false)
{
continue;
}
let Some(desk) = ctx.layout.home_desk(agent.desk_index.single_floor_local()) else {
continue;
};
let Some(color) =
crate::tui::pixel_painter::palette::tool_glow_tint(agent, &ctx.theme.tool_glow)
else {
continue;
};
halos.push(CeilingHalo {
x: desk.x + 6,
y: desk.y.saturating_sub(1),
color,
intensity: 0.8,
});
}
halos
}
pub(super) fn paint_dust_motes(
buf: &mut RgbBuffer,
theme: &Theme,
layout: &Layout,
floor_seed: u64,
now: SystemTime,
) {
if sun_on_wall(now).is_none() {
return;
}
let beam = weather_light(weather_state(now)).beam_strength;
if beam <= 0.0 {
return;
}
let look = time_of_day_look(now, theme);
let visibility = look.spill_strength * beam;
if visibility <= 0.0 {
return;
}
let warm = theme.lighting.sun_spill;
for col in window_spill_columns(layout) {
for DustMote { x, y, alpha } in dust_mote_positions(floor_seed, now, &col) {
if x >= buf.width || y >= buf.height {
continue;
}
let cur = buf.get(x, y);
let strength = alpha * 0.7 * visibility;
buf.put(x, y, blend_rgb(cur, warm, strength));
}
}
}
pub(super) fn paint_sun_spot(buf: &mut RgbBuffer, theme: &Theme, layout: &Layout, now: SystemTime) {
let Some(spot) = sun_on_wall(now) else {
return;
};
if matches!(spot.wall, WallSide::South) {
return;
}
let beam = weather_light(weather_state(now)).beam_strength;
if beam <= 0.0 {
return;
}
let look = time_of_day_look(now, theme);
let effective_intensity = spot.intensity * look.spill_strength * beam;
if effective_intensity <= 0.0 {
return;
}
let warm = theme.lighting.sun_spill;
let cool = 1.0 - spot.warmth;
let white = Rgb {
r: 255,
g: 255,
b: 255,
};
let color = blend_rgb(warm, white, cool * 0.6);
let base_w = 10u16;
let base_h = 4u16;
let w = (((base_w as f32) * effective_intensity).round() as u16).max(7);
let h = (((base_h as f32) * effective_intensity).round() as u16).max(3);
let wall_band_h = layout
.top_margin
.saturating_sub(pixtuoid_core::layout::WALL_BAND_TO_TOP_MARGIN);
if wall_band_h == 0 {
return;
}
let along_range = wall_band_h.saturating_sub(h) as f32;
let (rx, ry) = match spot.wall {
WallSide::East => {
let along_px = along_range * spot.along.min(1.0);
let cx = layout.buf_w.saturating_sub(w);
(cx, along_px as u16)
}
WallSide::West => {
let along_px = along_range * spot.along.min(1.0);
(0u16, along_px as u16)
}
WallSide::South => unreachable!("guarded above"),
};
let tint_strength = (0.45 + 0.35 * effective_intensity).min(0.7);
let max_x = (rx + w).min(buf.width);
let max_y = (ry + h).min(buf.height);
let cx = rx as f32 + (w.saturating_sub(1)) as f32 * 0.5;
let cy = ry as f32 + (h.saturating_sub(1)) as f32 * 0.5;
let rx_norm = ((w.saturating_sub(1)) as f32 * 0.5).max(1.0);
let ry_norm = ((h.saturating_sub(1)) as f32 * 0.5).max(1.0);
for y in ry..max_y {
for x in rx..max_x {
let nx = (x as f32 - cx) / rx_norm;
let ny = (y as f32 - cy) / ry_norm;
let r2 = nx * nx + ny * ny;
if r2 > 1.0 {
continue;
}
let t = (1.0 - r2) * tint_strength;
let cur = buf.get(x, y);
buf.put(x, y, blend_rgb(cur, color, t));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dust_mote_positions_deterministic_per_seed() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(12 * 3600 + 5);
let col = SunbeamColumn {
x: 100,
top_y: 12,
depth: 12,
};
let a = dust_mote_positions(42, now, &col);
let b = dust_mote_positions(42, now, &col);
assert_eq!(a, b, "same seed + time → same positions");
assert_eq!(a.len(), MOTES_PER_COLUMN);
}
#[test]
fn dust_motes_drift_over_time() {
let now1 = SystemTime::UNIX_EPOCH + Duration::from_secs(12 * 3600);
let now2 = now1 + Duration::from_millis(500);
let col = SunbeamColumn {
x: 100,
top_y: 12,
depth: 12,
};
let a = dust_mote_positions(7, now1, &col);
let b = dust_mote_positions(7, now2, &col);
assert_ne!(a, b, "positions should advance over time");
}
#[test]
fn ceiling_halo_painted_on_dark_theme() {
let mut buf = RgbBuffer::filled(160, 90, Rgb { r: 0, g: 0, b: 0 });
let theme = &crate::tui::theme::CYBERPUNK;
let halos = vec![CeilingHalo {
x: 50,
y: 10,
color: Rgb {
r: 0,
g: 200,
b: 255,
},
intensity: 0.8,
}];
let baseline = buf.get(50, 10);
paint_ceiling_halos(&mut buf, theme, &halos);
assert_ne!(baseline, buf.get(50, 10), "halo should brighten the pixel");
}
#[test]
fn ceiling_halo_skipped_on_light_theme() {
let mut buf = RgbBuffer::filled(160, 90, Rgb { r: 0, g: 0, b: 0 });
let theme = &crate::tui::theme::NORMAL;
let halos = vec![CeilingHalo {
x: 50,
y: 10,
color: Rgb {
r: 0,
g: 200,
b: 255,
},
intensity: 0.8,
}];
let baseline = buf.get(50, 10);
paint_ceiling_halos(&mut buf, theme, &halos);
assert_eq!(baseline, buf.get(50, 10), "no halo on light themes");
}
#[test]
fn dust_motes_alpha_fades_at_edges() {
let col = SunbeamColumn {
x: 100,
top_y: 12,
depth: 20,
};
let mut saw_partial = false;
'outer: for ms in 0..5000u64 {
let now = SystemTime::UNIX_EPOCH + Duration::from_millis(ms * 50);
for DustMote { alpha, .. } in dust_mote_positions(123, now, &col) {
if alpha < 0.5 {
saw_partial = true;
break 'outer;
}
}
}
assert!(
saw_partial,
"expected at least one frame where a mote is in its fade band"
);
}
#[test]
fn sun_spot_scales_with_beam_strength() {
use crate::tui::pixel_painter::background::Weather;
use chrono::TimeZone;
let theme = &crate::tui::theme::NORMAL;
let layout = crate::tui::layout::Layout::compute(192, 80, 4).expect("layout fits");
let morning = |day: u32| -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, day, 7, 0, 0)
.single()
.unwrap()
.into()
};
let find = |want: Weather| (1..=60u32).map(morning).find(|t| weather_state(*t) == want);
let clear_t = find(Weather::Clear).expect("a clear morning");
let snow_t = find(Weather::Snow).expect("a snow morning");
let rain_t = find(Weather::Rain).expect("a rain morning");
let brightness = |now: SystemTime| -> u64 {
let mut buf = RgbBuffer::filled(
192,
80,
Rgb {
r: 20,
g: 20,
b: 24,
},
);
paint_sun_spot(&mut buf, theme, &layout, now);
let mut sum = 0u64;
for y in 0..buf.height {
for x in 0..buf.width {
let p = buf.get(x, y);
sum += p.r as u64 + p.g as u64 + p.b as u64;
}
}
sum
};
let base = 192u64 * 80 * (20 + 20 + 24);
let clear = brightness(clear_t);
let snow = brightness(snow_t);
let rain = brightness(rain_t);
assert!(
clear > snow,
"clear beam brighter than snow ({clear} vs {snow})"
);
assert!(
snow > base,
"snow still throws a faint spot ({snow} vs {base})"
);
assert_eq!(rain, base, "rain has no direct beam → no sun spot");
}
#[test]
fn sun_spot_zero_intensity_at_hour_edge_leaves_buffer_untouched() {
use crate::tui::pixel_painter::background::Weather;
use chrono::TimeZone;
let theme = &crate::tui::theme::NORMAL;
let layout = crate::tui::layout::Layout::compute(192, 80, 4).expect("layout fits");
let dusk_edge = |day: u32| -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, day, 19, 30, 0)
.single()
.unwrap()
.into()
};
let beam_bearing = |w: Weather| {
matches!(
w,
Weather::Clear | Weather::Windy | Weather::Snow | Weather::Smog | Weather::Fog
)
};
let now = (1..=120u32)
.map(dusk_edge)
.find(|t| beam_bearing(weather_state(*t)))
.expect("a beam-bearing dusk-edge bucket exists");
let spot = sun_on_wall(now).expect("sun is up at the boundary");
assert!(
!matches!(spot.wall, WallSide::South),
"19:30 must be a West-wall spot, not the South window"
);
assert_eq!(spot.intensity, 0.0, "boundary_fade=0 → zero intensity");
let fill = Rgb {
r: 20,
g: 20,
b: 24,
};
let mut buf = RgbBuffer::filled(192, 80, fill);
paint_sun_spot(&mut buf, theme, &layout, now);
for y in 0..buf.height {
for x in 0..buf.width {
assert_eq!(
buf.get(x, y),
fill,
"zero-intensity sun spot must paint nothing"
);
}
}
}
#[test]
fn ceiling_halo_near_edge_does_not_panic() {
let mut buf = RgbBuffer::filled(6, 4, Rgb { r: 0, g: 0, b: 0 });
let theme = &crate::tui::theme::CYBERPUNK; let halos = vec![CeilingHalo {
x: 5,
y: 0,
color: Rgb {
r: 0,
g: 200,
b: 255,
},
intensity: 0.8,
}];
paint_ceiling_halos(&mut buf, theme, &halos);
}
#[test]
fn dust_motes_clamp_to_a_tiny_buffer() {
use chrono::TimeZone;
let theme = &crate::tui::theme::NORMAL;
let layout = crate::tui::layout::Layout::compute(192, 80, 4).expect("layout fits");
let now = (1..=60u32)
.map(|day| -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, day, 7, 0, 0)
.single()
.unwrap()
.into()
})
.find(|t| weather_state(*t) == crate::tui::pixel_painter::background::Weather::Clear)
.expect("a clear morning");
let fill = Rgb { r: 0, g: 0, b: 0 };
let mut buf = RgbBuffer::filled(1, 1, fill);
paint_dust_motes(&mut buf, theme, &layout, 7, now);
}
#[test]
fn sun_spot_zero_wall_band_returns_early() {
use chrono::TimeZone;
let theme = &crate::tui::theme::NORMAL;
let mut layout = crate::tui::layout::Layout::compute(192, 80, 4).expect("layout fits");
layout.top_margin = pixtuoid_core::layout::WALL_BAND_TO_TOP_MARGIN;
let clear_morning = (1..=60u32)
.map(|day| -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, day, 7, 0, 0)
.single()
.unwrap()
.into()
})
.find(|t| weather_state(*t) == crate::tui::pixel_painter::background::Weather::Clear)
.expect("a clear morning");
let fill = Rgb {
r: 20,
g: 20,
b: 24,
};
let mut buf = RgbBuffer::filled(layout.buf_w, layout.buf_h, fill);
paint_sun_spot(&mut buf, theme, &layout, clear_morning);
for y in 0..buf.height {
for x in 0..buf.width {
assert_eq!(buf.get(x, y), fill, "zero wall band → no sun spot");
}
}
}
}