src/light.rs 5.9 K · 198 lines · raw · history

1 use bevy::prelude::*;
2
3 use crate::{
4 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 structures::{Structure, StructureKind},
6 world::{TilePos, WorldMap},
7 };
8
9 pub struct LightPlugin;
10
11 impl Plugin for LightPlugin {
12 fn build(&self, app: &mut App) {
13 app.init_resource::<LightMap>()
14 .add_systems(FixedUpdate, update_light_map.in_set(SimSystems::Decide));
15 }
16 }
17
18 pub const LIT_BRIGHTNESS: f32 = 0.5;
19 pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2;
20 const AMBIENT_STEPS: f32 = 16.0;
21 const DAY_COLOR: Vec3 = Vec3::ONE;
22 const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
23
24 pub fn daylight(hour: f32) -> f32 {
25 match hour {
26 h if h < 5.0 => 0.0,
27 h if h < 7.0 => (h - 5.0) / 2.0,
28 h if h < 18.0 => 1.0,
29 h if h < 20.0 => 1.0 - (h - 18.0) / 2.0,
30 _ => 0.0,
31 }
32 }
33
34 pub fn ambient_color(daylight: f32) -> Vec3 {
35 NIGHT_COLOR.lerp(DAY_COLOR, daylight)
36 }
37
38 pub fn brightness(color: Vec3) -> f32 {
39 (color.x + color.y + color.z) / 3.0
40 }
41
42 pub fn dark_soon(tick: SimTick) -> bool {
43 let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
44 daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS
45 }
46
47 pub fn light_at(kind: StructureKind, source: UVec2, tile: UVec2) -> Vec3 {
48 let Some((radius, color)) = kind.light() else {
49 return Vec3::ZERO;
50 };
51 let d = source.as_vec2().distance(tile.as_vec2()) / radius;
52 if d >= 1.0 {
53 return Vec3::ZERO;
54 }
55 color * (1.0 - d * d).powi(2)
56 }
57
58 pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
59 sources
60 .into_iter()
61 .map(|(pos, kind)| light_at(kind, pos, tile))
62 .sum()
63 }
64
65 #[derive(Resource, Default, Debug, Clone, PartialEq)]
66 pub struct LightMap {
67 size: UVec2,
68 ambient_step: u32,
69 ambient: Vec3,
70 sources: Vec<Vec3>,
71 }
72
73 impl LightMap {
74 pub fn color(&self, tile: UVec2) -> Vec3 {
75 if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y {
76 return Vec3::ONE;
77 }
78 (self.ambient + self.sources[(tile.y * self.size.x + tile.x) as usize]).min(Vec3::ONE)
79 }
80
81 pub fn is_lit(&self, tile: UVec2) -> bool {
82 brightness(self.color(tile)) >= LIT_BRIGHTNESS
83 }
84
85 pub fn compute(
86 size: UVec2,
87 daylight: f32,
88 sources: impl IntoIterator<Item = (UVec2, StructureKind)>,
89 ) -> Self {
90 let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
91 let mut light = vec![Vec3::ZERO; size.element_product() as usize];
92 for (pos, kind) in sources {
93 let Some((radius, _)) = kind.light() else {
94 continue;
95 };
96 let r = radius.ceil() as i32;
97 for dy in -r..=r {
98 for dx in -r..=r {
99 let tile = pos.as_ivec2() + IVec2::new(dx, dy);
100 if tile.x < 0
101 || tile.y < 0
102 || tile.x >= size.x as i32
103 || tile.y >= size.y as i32
104 {
105 continue;
106 }
107 let tile = tile.as_uvec2();
108 light[(tile.y * size.x + tile.x) as usize] += light_at(kind, pos, tile);
109 }
110 }
111 }
112 Self {
113 size,
114 ambient_step,
115 ambient: ambient_color(ambient_step as f32 / AMBIENT_STEPS),
116 sources: light,
117 }
118 }
119 }
120
121 fn update_light_map(
122 tick: Res<SimTick>,
123 map: Res<WorldMap>,
124 structures: Query<(&TilePos, &Structure)>,
125 added: Query<(), Added<Structure>>,
126 mut removed: RemovedComponents<Structure>,
127 mut light: ResMut<LightMap>,
128 ) {
129 let day = daylight(hour_of_day(*tick));
130 let step = (day * AMBIENT_STEPS).round() as u32;
131 let sources_changed = !added.is_empty() || removed.read().count() > 0;
132 if !sources_changed && step == light.ambient_step && light.size == map.size {
133 return;
134 }
135 *light = LightMap::compute(
136 map.size,
137 day,
138 structures.iter().map(|(pos, s)| (pos.0, s.kind)),
139 );
140 }
141
142 #[cfg(test)]
143 mod tests {
144 use super::*;
145 use crate::sim::TICKS_PER_HOUR;
146
147 #[test]
148 fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
149 assert_eq!(daylight(12.0), 1.0);
150 assert_eq!(daylight(23.0), 0.0);
151 assert_eq!(daylight(2.0), 0.0);
152 assert_eq!(daylight(19.0), 0.5);
153 assert_eq!(daylight(6.0), 0.5);
154 }
155
156 // Building a fire takes time, so colonists start before it actually gets dark.
157 #[test]
158 fn darkness_is_anticipated_before_dusk() {
159 let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR);
160 assert!(!dark_soon(at(12)));
161 assert!(dark_soon(at(18)));
162 assert!(dark_soon(at(23)));
163 assert!(!dark_soon(at(8)));
164 }
165
166 #[test]
167 fn fires_light_their_surroundings_with_falloff() {
168 let fire = UVec2::new(10, 10);
169 let near = light_at(StructureKind::Campfire, fire, UVec2::new(11, 10));
170 let far = light_at(StructureKind::Campfire, fire, UVec2::new(15, 10));
171 assert!(brightness(near) > brightness(far));
172 assert_eq!(
173 light_at(StructureKind::Campfire, fire, UVec2::new(17, 10)),
174 Vec3::ZERO
175 );
176 }
177
178 #[test]
179 fn light_map_combines_ambient_and_fires() {
180 let fire = (UVec2::new(5, 5), StructureKind::Campfire);
181 let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]);
182 assert!(night.is_lit(UVec2::new(5, 6)));
183 assert!(!night.is_lit(UVec2::new(18, 18)));
184 let day = LightMap::compute(UVec2::splat(20), 1.0, []);
185 assert!(day.is_lit(UVec2::new(18, 18)));
186 }
187
188 #[test]
189 fn an_empty_light_map_counts_as_daylight() {
190 assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
191 }
192
193 #[test]
194 fn beds_give_no_light() {
195 let bed = UVec2::new(4, 4);
196 assert_eq!(light_at(StructureKind::Bed, bed, bed), Vec3::ZERO);
197 }
198 }