src/light.rs 10 K · 344 lines · raw · history

1 use bevy::prelude::*;
2
3 use crate::{
4 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 world::{TILE_SIZE, TilePos, WorldMap},
6 worldgen::WorldGenerated,
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(Startup, create_structure_assets)
15 .add_systems(
16 Update,
17 clear_structures.run_if(on_message::<WorldGenerated>),
18 )
19 .add_systems(
20 FixedUpdate,
21 (
22 update_light_map.in_set(SimSystems::Decide),
23 burn_fuel.in_set(SimSystems::Act),
24 ),
25 );
26 }
27 }
28
29 pub const LIT_BRIGHTNESS: f32 = 0.5;
30 pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2;
31 const AMBIENT_STEPS: f32 = 16.0;
32 const DAY_COLOR: Vec3 = Vec3::ONE;
33 const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
34
35 pub fn daylight(hour: f32) -> f32 {
36 match hour {
37 h if h < 5.0 => 0.0,
38 h if h < 7.0 => (h - 5.0) / 2.0,
39 h if h < 18.0 => 1.0,
40 h if h < 20.0 => 1.0 - (h - 18.0) / 2.0,
41 _ => 0.0,
42 }
43 }
44
45 pub fn ambient_color(daylight: f32) -> Vec3 {
46 NIGHT_COLOR.lerp(DAY_COLOR, daylight)
47 }
48
49 pub fn brightness(color: Vec3) -> f32 {
50 (color.x + color.y + color.z) / 3.0
51 }
52
53 pub fn dark_soon(tick: SimTick) -> bool {
54 let later = SimTick(tick.0 + LIGHT_LOOKAHEAD_TICKS);
55 daylight(hour_of_day(tick)) < LIT_BRIGHTNESS || daylight(hour_of_day(later)) < LIT_BRIGHTNESS
56 }
57
58 #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
59 pub enum StructureKind {
60 Campfire,
61 Torch,
62 }
63
64 impl StructureKind {
65 pub fn wood_cost(self) -> u32 {
66 match self {
67 StructureKind::Campfire => 10,
68 StructureKind::Torch => 2,
69 }
70 }
71
72 pub fn fuel_ticks(self) -> u32 {
73 let hours = match self {
74 StructureKind::Campfire => 12,
75 StructureKind::Torch => 8,
76 };
77 hours * TICKS_PER_HOUR as u32
78 }
79
80 pub fn radius(self) -> f32 {
81 match self {
82 StructureKind::Campfire => 7.0,
83 StructureKind::Torch => 4.0,
84 }
85 }
86
87 fn light_color(self) -> Vec3 {
88 match self {
89 StructureKind::Campfire => Vec3::new(1.1, 0.8, 0.5),
90 StructureKind::Torch => Vec3::new(1.0, 0.8, 0.55),
91 }
92 }
93
94 pub fn light_at(self, source: UVec2, tile: UVec2) -> Vec3 {
95 let d = source.as_vec2().distance(tile.as_vec2()) / self.radius();
96 if d >= 1.0 {
97 return Vec3::ZERO;
98 }
99 self.light_color() * (1.0 - d * d).powi(2)
100 }
101 }
102
103 #[derive(Component, Debug, Clone, Copy, PartialEq)]
104 #[require(Transform, Visibility)]
105 pub struct Structure {
106 pub kind: StructureKind,
107 pub fuel: u32,
108 }
109
110 pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
111 sources
112 .into_iter()
113 .map(|(pos, kind)| kind.light_at(pos, tile))
114 .sum()
115 }
116
117 #[derive(Resource, Default, Debug, Clone, PartialEq)]
118 pub struct LightMap {
119 size: UVec2,
120 ambient_step: u32,
121 ambient: Vec3,
122 sources: Vec<Vec3>,
123 }
124
125 impl LightMap {
126 pub fn color(&self, tile: UVec2) -> Vec3 {
127 if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y {
128 return Vec3::ONE;
129 }
130 (self.ambient + self.sources[(tile.y * self.size.x + tile.x) as usize]).min(Vec3::ONE)
131 }
132
133 pub fn is_lit(&self, tile: UVec2) -> bool {
134 brightness(self.color(tile)) >= LIT_BRIGHTNESS
135 }
136
137 pub fn compute(
138 size: UVec2,
139 daylight: f32,
140 sources: impl IntoIterator<Item = (UVec2, StructureKind)>,
141 ) -> Self {
142 let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
143 let mut light = vec![Vec3::ZERO; size.element_product() as usize];
144 for (pos, kind) in sources {
145 let r = kind.radius().ceil() as i32;
146 for dy in -r..=r {
147 for dx in -r..=r {
148 let tile = pos.as_ivec2() + IVec2::new(dx, dy);
149 if tile.x < 0
150 || tile.y < 0
151 || tile.x >= size.x as i32
152 || tile.y >= size.y as i32
153 {
154 continue;
155 }
156 let tile = tile.as_uvec2();
157 light[(tile.y * size.x + tile.x) as usize] += kind.light_at(pos, tile);
158 }
159 }
160 }
161 Self {
162 size,
163 ambient_step,
164 ambient: ambient_color(ambient_step as f32 / AMBIENT_STEPS),
165 sources: light,
166 }
167 }
168 }
169
170 fn update_light_map(
171 tick: Res<SimTick>,
172 map: Res<WorldMap>,
173 structures: Query<(&TilePos, &Structure)>,
174 added: Query<(), Added<Structure>>,
175 mut removed: RemovedComponents<Structure>,
176 mut light: ResMut<LightMap>,
177 ) {
178 let day = daylight(hour_of_day(*tick));
179 let step = (day * AMBIENT_STEPS).round() as u32;
180 let sources_changed = !added.is_empty() || removed.read().count() > 0;
181 if !sources_changed && step == light.ambient_step && light.size == map.size {
182 return;
183 }
184 *light = LightMap::compute(
185 map.size,
186 day,
187 structures.iter().map(|(pos, s)| (pos.0, s.kind)),
188 );
189 }
190
191 fn burn_fuel(mut commands: Commands, mut structures: Query<(Entity, &mut Structure)>) {
192 for (entity, mut structure) in &mut structures {
193 structure.fuel = structure.fuel.saturating_sub(1);
194 if structure.fuel == 0 {
195 commands.entity(entity).despawn();
196 }
197 }
198 }
199
200 fn clear_structures(mut commands: Commands, structures: Query<Entity, With<Structure>>) {
201 for entity in &structures {
202 commands.entity(entity).despawn();
203 }
204 }
205
206 #[derive(Resource, Default)]
207 pub struct StructureAssets {
208 ring: (Handle<Mesh>, Handle<ColorMaterial>),
209 flame: (Handle<Mesh>, Handle<ColorMaterial>),
210 stick: (Handle<Mesh>, Handle<ColorMaterial>),
211 torch_flame: (Handle<Mesh>, Handle<ColorMaterial>),
212 }
213
214 fn create_structure_assets(
215 mut commands: Commands,
216 mut meshes: ResMut<Assets<Mesh>>,
217 mut materials: ResMut<Assets<ColorMaterial>>,
218 ) {
219 let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
220 commands.insert_resource(StructureAssets {
221 ring: part(
222 Circle::new(TILE_SIZE * 0.45).into(),
223 Color::srgb_u8(90, 86, 80),
224 ),
225 flame: part(
226 Circle::new(TILE_SIZE * 0.28).into(),
227 Color::srgb_u8(255, 150, 40),
228 ),
229 stick: part(
230 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
231 Color::srgb_u8(100, 70, 40),
232 ),
233 torch_flame: part(
234 Circle::new(TILE_SIZE * 0.16).into(),
235 Color::srgb_u8(255, 200, 70),
236 ),
237 });
238 }
239
240 pub fn spawn_structure(
241 commands: &mut Commands,
242 assets: &StructureAssets,
243 tile: UVec2,
244 kind: StructureKind,
245 ) {
246 let part = |(mesh, material): &(Handle<Mesh>, Handle<ColorMaterial>), offset: Vec3| {
247 (
248 Mesh2d(mesh.clone()),
249 MeshMaterial2d(material.clone()),
250 Transform::from_translation(offset),
251 )
252 };
253 let mut entity = commands.spawn((
254 Name::new(format!("{kind:?}")),
255 Structure {
256 kind,
257 fuel: kind.fuel_ticks(),
258 },
259 TilePos(tile),
260 Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
261 ));
262 match kind {
263 StructureKind::Campfire => entity.with_children(|c| {
264 c.spawn(part(&assets.ring, Vec3::ZERO));
265 c.spawn(part(&assets.flame, Vec3::Z * 0.01));
266 }),
267 StructureKind::Torch => entity.with_children(|c| {
268 c.spawn(part(&assets.stick, Vec3::new(0.0, -TILE_SIZE * 0.1, 0.0)));
269 c.spawn(part(
270 &assets.torch_flame,
271 Vec3::new(0.0, TILE_SIZE * 0.2, 0.01),
272 ));
273 }),
274 };
275 }
276
277 #[cfg(test)]
278 mod tests {
279 use super::*;
280 use crate::sim::TICKS_PER_HOUR;
281
282 #[test]
283 fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
284 assert_eq!(daylight(12.0), 1.0);
285 assert_eq!(daylight(23.0), 0.0);
286 assert_eq!(daylight(2.0), 0.0);
287 assert_eq!(daylight(19.0), 0.5);
288 assert_eq!(daylight(6.0), 0.5);
289 }
290
291 // Building a fire takes time, so colonists start before it actually gets dark.
292 #[test]
293 fn darkness_is_anticipated_before_dusk() {
294 let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR);
295 assert!(!dark_soon(at(12)));
296 assert!(dark_soon(at(18)));
297 assert!(dark_soon(at(23)));
298 assert!(!dark_soon(at(8)));
299 }
300
301 #[test]
302 fn fires_light_their_surroundings_with_falloff() {
303 let fire = UVec2::new(10, 10);
304 let near = StructureKind::Campfire.light_at(fire, UVec2::new(11, 10));
305 let far = StructureKind::Campfire.light_at(fire, UVec2::new(15, 10));
306 assert!(brightness(near) > brightness(far));
307 assert_eq!(
308 StructureKind::Campfire.light_at(fire, UVec2::new(17, 10)),
309 Vec3::ZERO
310 );
311 }
312
313 #[test]
314 fn light_map_combines_ambient_and_fires() {
315 let fire = (UVec2::new(5, 5), StructureKind::Campfire);
316 let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]);
317 assert!(night.is_lit(UVec2::new(5, 6)));
318 assert!(!night.is_lit(UVec2::new(18, 18)));
319 let day = LightMap::compute(UVec2::splat(20), 1.0, []);
320 assert!(day.is_lit(UVec2::new(18, 18)));
321 }
322
323 #[test]
324 fn an_empty_light_map_counts_as_daylight() {
325 assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
326 }
327
328 #[test]
329 fn structures_burn_out() {
330 let mut app = App::new();
331 app.add_systems(Update, burn_fuel);
332 let fire = app
333 .world_mut()
334 .spawn(Structure {
335 kind: StructureKind::Torch,
336 fuel: 3,
337 })
338 .id();
339 for _ in 0..3 {
340 app.update();
341 }
342 assert!(app.world().get_entity(fire).is_err());
343 }
344 }