use std::cell::Cell;
use std::time::SystemTime;
use pixtuoid_core::sprite::{Rgb, RgbBuffer};
use crate::tui::pixel_painter::palette::{blend_rgb, lerp_rgb};
use crate::tui::theme::Theme;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub(in crate::tui::pixel_painter) enum Weather {
Clear,
Rain,
Storm,
Snow,
Fog,
Overcast,
Windy,
Smog,
}
impl Weather {
pub(in crate::tui::pixel_painter) const ALL: [Weather; 8] = [
Weather::Clear,
Weather::Rain,
Weather::Storm,
Weather::Snow,
Weather::Fog,
Weather::Overcast,
Weather::Windy,
Weather::Smog,
];
pub(in crate::tui::pixel_painter) const fn name(self) -> &'static str {
match self {
Weather::Clear => "clear",
Weather::Rain => "rain",
Weather::Storm => "storm",
Weather::Snow => "snow",
Weather::Fog => "fog",
Weather::Overcast => "overcast",
Weather::Windy => "windy",
Weather::Smog => "smog",
}
}
pub(in crate::tui::pixel_painter) fn from_name(s: &str) -> Option<Weather> {
let s = s.trim().to_ascii_lowercase();
Weather::ALL.into_iter().find(|w| w.name() == s)
}
}
thread_local! {
static WEATHER_OVERRIDE: Cell<Option<Weather>> = const { Cell::new(None) };
}
pub(in crate::tui::pixel_painter) fn set_weather_override(w: Option<Weather>) {
WEATHER_OVERRIDE.with(|c| c.set(w));
}
pub(in crate::tui::pixel_painter) fn weather_state(now: SystemTime) -> Weather {
if let Some(forced) = WEATHER_OVERRIDE.with(Cell::get) {
return forced;
}
let secs = now
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let cycle = secs / 600;
let mut h = cycle.wrapping_add(0x9e37_79b9_7f4a_7c15);
h = (h ^ (h >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
h = (h ^ (h >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
h ^= h >> 31;
match h % 15 {
0..=5 => Weather::Clear,
6..=7 => Weather::Rain,
8 => Weather::Storm,
9 => Weather::Snow,
10 => Weather::Fog,
11..=12 => Weather::Overcast,
13 => Weather::Windy,
_ => Weather::Smog,
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub(in crate::tui::pixel_painter) struct WeatherLight {
pub intensity: f32,
pub beam_strength: f32,
pub night_sky: f32,
}
pub(in crate::tui::pixel_painter) fn weather_light(w: Weather) -> WeatherLight {
let (intensity, beam_strength, night_sky) = match w {
Weather::Clear => (1.0, 1.0, 0.35),
Weather::Windy => (1.0, 0.9, 0.32),
Weather::Snow => (0.75, 0.25, 0.40),
Weather::Smog => (0.55, 0.30, 0.22),
Weather::Fog => (0.55, 0.05, 0.20),
Weather::Overcast => (0.45, 0.0, 0.14),
Weather::Storm => (0.42, 0.0, 0.08),
Weather::Rain => (0.40, 0.0, 0.12),
};
debug_assert!(
(0.0..=1.0).contains(&intensity)
&& (0.0..=1.0).contains(&beam_strength)
&& (0.0..=1.0).contains(&night_sky),
"WeatherLight channels must be 0..=1: {w:?} -> ({intensity}, {beam_strength}, {night_sky})"
);
WeatherLight {
intensity,
beam_strength,
night_sky,
}
}
pub(in crate::tui::pixel_painter) fn sunset_strength(now: SystemTime) -> f32 {
let h = super::local_hour_frac(now);
crate::tui::pixel_painter::palette::bell(h, 18.0, 1.5)
.max(crate::tui::pixel_painter::palette::bell(h, 6.5, 1.0))
}
pub(in crate::tui::pixel_painter) struct TimeOfDayLook {
pub(in crate::tui::pixel_painter) glass_a: Rgb,
pub(in crate::tui::pixel_painter) glass_b: Rgb,
pub(in crate::tui::pixel_painter) spill_strength: f32,
pub(in crate::tui::pixel_painter) spill_slant: f32,
pub(in crate::tui::pixel_painter) darkness: f32,
pub(in crate::tui::pixel_painter) twilight: f32,
}
pub(in crate::tui::pixel_painter) fn time_of_day_look(
now: SystemTime,
theme: &Theme,
) -> TimeOfDayLook {
let h = super::local_hour_frac(now);
let day = if !(5.0..20.0).contains(&h) {
0.0
} else if h < 8.0 {
(h - 5.0) / 3.0
} else if h < 17.0 {
1.0
} else {
1.0 - (h - 17.0) / 3.0
};
let twilight = crate::tui::pixel_painter::palette::bell(h, 6.5, 1.5)
.max(crate::tui::pixel_painter::palette::bell(h, 18.5, 1.5));
let atmo = weather_light(weather_state(now));
let day_eff = day * atmo.intensity;
let twilight_eff = twilight * atmo.intensity;
let night_glow = atmo.night_sky * (1.0 - day);
let exterior = day_eff.max(night_glow);
let day_a = theme.lighting.day_sky_a;
let day_b = theme.lighting.day_sky_b;
let night_a = theme.lighting.night_sky_a;
let night_b = theme.lighting.night_sky_b;
let twilight_a = theme.lighting.twilight_a;
let twilight_b = theme.lighting.twilight_b;
let night_lift = night_glow * 0.18;
let glass_night_a = lerp_rgb(night_a, day_a, night_lift);
let glass_night_b = lerp_rgb(night_b, day_b, night_lift);
let glass_a = lerp_rgb(
lerp_rgb(glass_night_a, day_a, day_eff),
twilight_a,
twilight_eff * 0.5,
);
let glass_b = lerp_rgb(
lerp_rgb(glass_night_b, day_b, day_eff),
twilight_b,
twilight_eff * 0.5,
);
let slant = if h < 12.0 {
-((12.0 - h) / 6.0).clamp(0.0, 1.0) * 0.7
} else {
((h - 12.0) / 6.0).clamp(0.0, 1.0) * 0.7
};
TimeOfDayLook {
glass_a,
glass_b,
spill_strength: day_eff,
spill_slant: slant,
darkness: 1.0 - exterior,
twilight,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(in crate::tui::pixel_painter) enum WallSide {
East,
South,
West,
}
#[derive(Debug, Clone, Copy)]
pub(in crate::tui::pixel_painter) struct SunSpot {
pub wall: WallSide,
pub along: f32,
pub intensity: f32,
pub warmth: f32,
}
pub(in crate::tui::pixel_painter) fn sun_on_wall(now: SystemTime) -> Option<SunSpot> {
use chrono::Timelike;
const SUN_RAMP_HOURS: f32 = 0.5;
const LOWER: f32 = 6.0 - SUN_RAMP_HOURS;
const UPPER: f32 = 19.0 + SUN_RAMP_HOURS;
let unix_now = now.duration_since(std::time::UNIX_EPOCH).ok()?;
let local = chrono::DateTime::<chrono::Local>::from(std::time::UNIX_EPOCH + unix_now);
let hour = local.hour() as f32 + local.minute() as f32 / 60.0;
if !(LOWER..=UPPER).contains(&hour) {
return None;
}
let position_hour = hour.clamp(6.0, 19.0);
let (wall, along) = if position_hour < 8.5 {
(WallSide::East, (position_hour - 6.0) / 2.5)
} else if position_hour < 16.0 {
(WallSide::South, (position_hour - 8.5) / 7.5)
} else {
(WallSide::West, (position_hour - 16.0) / 3.0)
};
let noon_distance = (position_hour - 12.0).abs() / 6.0;
let boundary_fade = if hour < 6.0 {
((hour - LOWER) / SUN_RAMP_HOURS).clamp(0.0, 1.0)
} else if hour > 19.0 {
((UPPER - hour) / SUN_RAMP_HOURS).clamp(0.0, 1.0)
} else {
1.0
};
let intensity = (1.0 - noon_distance * 0.7).clamp(0.0, 1.0) * boundary_fade;
let warmth = noon_distance.clamp(0.0, 1.0);
Some(SunSpot {
wall,
along,
intensity,
warmth,
})
}
pub(in crate::tui::pixel_painter) fn dim_floor_overlay(
buf: &mut RgbBuffer,
top_y: u16,
bottom_y: u16,
strength: f32,
theme: &Theme,
) {
let night_tint = theme.lighting.night_tint;
let s = strength.clamp(0.0, 0.55);
for y in top_y..bottom_y.min(buf.height) {
for x in 0..buf.width {
let cur = buf.get(x, y);
buf.put(x, y, blend_rgb(cur, night_tint, s));
}
}
}
pub(in crate::tui::pixel_painter) fn daylight_floor_overlay(
buf: &mut RgbBuffer,
top_y: u16,
bottom_y: u16,
strength: f32,
) {
const SUN_TINT: Rgb = Rgb {
r: 255,
g: 246,
b: 224,
};
let s = strength.clamp(0.0, 0.40);
if s <= 0.0 {
return;
}
for y in top_y..bottom_y.min(buf.height) {
for x in 0..buf.width {
let cur = buf.get(x, y);
buf.put(x, y, blend_rgb(cur, SUN_TINT, s));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
#[test]
fn daylight_floor_overlay_brightens_at_positive_strength() {
let mut buf = RgbBuffer::filled(
4,
10,
Rgb {
r: 50,
g: 50,
b: 50,
},
);
daylight_floor_overlay(&mut buf, 2, 10, 0.30);
for y in 2..10u16 {
for x in 0..4u16 {
assert!(
buf.get(x, y).r > 50,
"floor pixel ({x},{y}) should brighten"
);
}
}
}
#[test]
fn daylight_floor_overlay_is_noop_at_zero_strength() {
let mut buf = RgbBuffer::filled(
4,
10,
Rgb {
r: 80,
g: 90,
b: 100,
},
);
daylight_floor_overlay(&mut buf, 2, 10, 0.0);
for y in 2..10u16 {
for x in 0..4u16 {
assert_eq!(
buf.get(x, y),
Rgb {
r: 80,
g: 90,
b: 100
},
"zero strength must not mutate pixels"
);
}
}
}
fn at_hour(h: u32, m: u32) -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, 1, h, m, 0)
.single()
.expect("local time should be unambiguous")
.into()
}
fn night_on(day: u32) -> SystemTime {
chrono::Local
.with_ymd_and_hms(2026, 1, day, 2, 0, 0)
.single()
.expect("local time should be unambiguous")
.into()
}
#[test]
fn time_of_day_look_night_darkness_tracks_weather() {
let theme = crate::tui::theme::ALL_THEMES[0];
let (mut clear_t, mut storm_t) = (None, None);
for day in 1..=28u32 {
let t = night_on(day);
match weather_state(t) {
Weather::Clear if clear_t.is_none() => clear_t = Some(t),
Weather::Storm if storm_t.is_none() => storm_t = Some(t),
_ => {}
}
}
let clear = time_of_day_look(clear_t.expect("a clear night in January"), theme);
let storm = time_of_day_look(storm_t.expect("a storm night in January"), theme);
assert!(
clear.darkness < storm.darkness,
"clear night ({}) must be brighter than storm night ({})",
clear.darkness,
storm.darkness
);
assert!(
storm.darkness < 1.0,
"even a storm night keeps some sky-glow (not pitch black): {}",
storm.darkness
);
let clear_noon: SystemTime = (1..=28u32)
.map(|d| {
chrono::Local
.with_ymd_and_hms(2026, 1, d, 12, 0, 0)
.single()
.unwrap()
.into()
})
.find(|t| weather_state(*t) == Weather::Clear)
.expect("a clear noon in January");
assert!(
time_of_day_look(clear_noon, theme).darkness < 0.1,
"a clear noon should be ~fully lit (day_eff dominates night_glow)"
);
}
#[test]
fn sun_on_wall_east_at_morning() {
let s = sun_on_wall(at_hour(7, 0)).expect("sun should be up at 07:00");
assert_eq!(s.wall, WallSide::East);
assert!(s.warmth > 0.5, "morning sun should be warm: {}", s.warmth);
}
#[test]
fn sun_on_wall_overhead_at_noon() {
let s = sun_on_wall(at_hour(12, 0)).expect("sun should be up at 12:00");
assert_eq!(s.wall, WallSide::South);
assert!(
s.intensity > 0.85,
"noon sun should be intense: {}",
s.intensity
);
}
#[test]
fn sun_on_wall_west_at_evening() {
let s = sun_on_wall(at_hour(18, 0)).expect("sun should be up at 18:00");
assert_eq!(s.wall, WallSide::West);
assert!(s.warmth > 0.6, "evening sun should be warm: {}", s.warmth);
}
#[test]
fn sun_on_wall_none_at_midnight() {
assert!(sun_on_wall(at_hour(0, 0)).is_none());
}
#[test]
fn atmo_clear_beams_hard() {
let a = weather_light(Weather::Clear);
assert_eq!(a.beam_strength, 1.0);
assert_eq!(a.intensity, 1.0);
assert!(weather_light(Weather::Windy).beam_strength > 0.5);
}
#[test]
fn atmo_thick_cloud_kills_the_beam() {
for w in [Weather::Rain, Weather::Storm, Weather::Overcast] {
let a = weather_light(w);
assert_eq!(a.beam_strength, 0.0, "{w:?} should have no direct beam");
assert!(a.intensity < 1.0, "{w:?} should dim diffuse light");
}
}
#[test]
fn atmo_haze_and_snow_keep_a_faint_beam() {
for w in [Weather::Snow, Weather::Fog, Weather::Smog] {
let b = weather_light(w).beam_strength;
assert!(b > 0.0 && b < 0.5, "{w:?} beam should be faint, got {b}");
}
}
#[test]
fn atmo_storm_dimmer_than_overcast() {
assert!(
weather_light(Weather::Storm).intensity < weather_light(Weather::Overcast).intensity
);
}
#[test]
fn atmo_storm_brighter_than_rain_by_design() {
assert!(
weather_light(Weather::Storm).intensity > weather_light(Weather::Rain).intensity,
"storm.intensity must exceed rain.intensity (lightning raises the average)"
);
}
#[test]
fn fog_is_a_luminous_whiteout_not_dark_mist() {
let fog = weather_light(Weather::Fog);
assert!(fog.intensity >= weather_light(Weather::Overcast).intensity);
assert!(fog.beam_strength < 0.2);
}
#[test]
fn smog_dims_diffusely() {
let a = weather_light(Weather::Smog);
assert!(a.beam_strength > 0.0 && a.beam_strength < 0.5); assert!(a.intensity > 0.4 && a.intensity < 0.7);
}
#[test]
fn night_sky_brightness_varies_by_weather() {
let clear = weather_light(Weather::Clear).night_sky;
let snow = weather_light(Weather::Snow).night_sky;
let storm = weather_light(Weather::Storm).night_sky;
let overcast = weather_light(Weather::Overcast).night_sky;
assert!(clear > overcast, "clear night should beat overcast");
assert!(snow >= clear, "snow-glow night should be the brightest");
assert!(
storm < overcast && storm < clear,
"storm night should be the darkest"
);
for w in [
Weather::Clear,
Weather::Windy,
Weather::Snow,
Weather::Smog,
Weather::Fog,
Weather::Overcast,
Weather::Rain,
Weather::Storm,
] {
assert!(weather_light(w).night_sky > 0.0, "{w:?} night_sky > 0");
}
}
#[test]
fn weather_state_emits_every_variant_within_a_week() {
use std::collections::HashSet;
use std::time::Duration;
let start = std::time::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let mut seen: HashSet<Weather> = HashSet::new();
for slot in 0..(7u64 * 24 * 6) {
seen.insert(weather_state(start + Duration::from_secs(slot * 600)));
}
for w in [
Weather::Clear,
Weather::Rain,
Weather::Storm,
Weather::Snow,
Weather::Fog,
Weather::Overcast,
Weather::Windy,
Weather::Smog,
] {
assert!(
seen.contains(&w),
"weather_state never emitted {w:?} in a week of slots"
);
}
}
#[test]
fn weather_name_round_trips_for_every_variant() {
for w in Weather::ALL {
assert_eq!(Weather::from_name(w.name()), Some(w), "{w:?} round-trips");
}
assert_eq!(Weather::from_name(" SNOW "), Some(Weather::Snow));
assert_eq!(Weather::from_name("drizzle"), None);
}
#[test]
fn weather_override_forces_a_fixed_variant_then_restores() {
use std::time::Duration;
struct Reset;
impl Drop for Reset {
fn drop(&mut self) {
set_weather_override(None);
}
}
let _reset = Reset;
let t = std::time::UNIX_EPOCH + Duration::from_secs(1_700_000_000);
let natural = weather_state(t);
let forced = Weather::ALL
.into_iter()
.find(|&w| w != natural)
.expect("8 variants");
set_weather_override(Some(forced));
assert_eq!(weather_state(t), forced);
assert_eq!(
weather_state(t + Duration::from_secs(987_654)),
forced,
"override is time-independent"
);
set_weather_override(None);
assert_eq!(
weather_state(t),
natural,
"None restores time-based selection"
);
}
}