src/light.rs 11 K · 378 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 type Part = (Handle<Mesh>, Handle<ColorMaterial>);
207
208 #[derive(Resource, Default)]
209 pub struct StructureAssets {
210 stone: Part,
211 log: Part,
212 flame: Part,
213 flame_core: Part,
214 stick: Part,
215 }
216
217 const CAMPFIRE_STONES: usize = 7;
218
219 fn flame_mesh(width: f32, height: f32) -> Mesh {
220 Triangle2d::new(
221 Vec2::new(-width / 2.0, -height / 2.0),
222 Vec2::new(width / 2.0, -height / 2.0),
223 Vec2::new(0.0, height / 2.0),
224 )
225 .into()
226 }
227
228 fn create_structure_assets(
229 mut commands: Commands,
230 mut meshes: ResMut<Assets<Mesh>>,
231 mut materials: ResMut<Assets<ColorMaterial>>,
232 ) {
233 let mut part = |mesh: Mesh, color: Color| (meshes.add(mesh), materials.add(color));
234 commands.insert_resource(StructureAssets {
235 stone: part(
236 Circle::new(TILE_SIZE * 0.09).into(),
237 Color::srgb_u8(120, 116, 110),
238 ),
239 log: part(
240 Rectangle::new(TILE_SIZE * 0.7, TILE_SIZE * 0.14).into(),
241 Color::srgb_u8(110, 74, 42),
242 ),
243 flame: part(
244 flame_mesh(TILE_SIZE * 0.36, TILE_SIZE * 0.5),
245 Color::srgb_u8(240, 110, 30),
246 ),
247 flame_core: part(
248 flame_mesh(TILE_SIZE * 0.18, TILE_SIZE * 0.28),
249 Color::srgb_u8(255, 220, 90),
250 ),
251 stick: part(
252 Rectangle::new(TILE_SIZE * 0.12, TILE_SIZE * 0.5).into(),
253 Color::srgb_u8(100, 70, 40),
254 ),
255 });
256 }
257
258 fn part_bundle((mesh, material): &Part, transform: Transform) -> impl Bundle {
259 (
260 Mesh2d(mesh.clone()),
261 MeshMaterial2d(material.clone()),
262 transform,
263 )
264 }
265
266 pub fn spawn_structure(
267 commands: &mut Commands,
268 assets: &StructureAssets,
269 tile: UVec2,
270 kind: StructureKind,
271 ) {
272 let mut entity = commands.spawn((
273 Name::new(format!("{kind:?}")),
274 Structure {
275 kind,
276 fuel: kind.fuel_ticks(),
277 },
278 TilePos(tile),
279 Transform::from_translation(WorldMap::tile_center(tile).extend(0.8)),
280 ));
281 let at = |x: f32, y: f32, z: f32| Transform::from_xyz(x * TILE_SIZE, y * TILE_SIZE, z);
282 match kind {
283 StructureKind::Campfire => entity.with_children(|c| {
284 for i in 0..CAMPFIRE_STONES {
285 let angle = i as f32 / CAMPFIRE_STONES as f32 * std::f32::consts::TAU;
286 let pos = Vec2::from_angle(angle) * 0.38;
287 c.spawn(part_bundle(&assets.stone, at(pos.x, pos.y, 0.0)));
288 }
289 for angle in [0.6_f32, -0.6] {
290 let log = at(0.0, -0.08, 0.01).with_rotation(Quat::from_rotation_z(angle));
291 c.spawn(part_bundle(&assets.log, log));
292 }
293 c.spawn(part_bundle(&assets.flame, at(0.0, 0.08, 0.02)));
294 c.spawn(part_bundle(&assets.flame_core, at(0.0, 0.03, 0.03)));
295 }),
296 StructureKind::Torch => entity.with_children(|c| {
297 c.spawn(part_bundle(&assets.stick, at(0.0, -0.15, 0.0)));
298 let small = Vec3::splat(0.6);
299 c.spawn(part_bundle(
300 &assets.flame,
301 at(0.0, 0.18, 0.01).with_scale(small),
302 ));
303 c.spawn(part_bundle(
304 &assets.flame_core,
305 at(0.0, 0.15, 0.02).with_scale(small),
306 ));
307 }),
308 };
309 }
310
311 #[cfg(test)]
312 mod tests {
313 use super::*;
314 use crate::sim::TICKS_PER_HOUR;
315
316 #[test]
317 fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
318 assert_eq!(daylight(12.0), 1.0);
319 assert_eq!(daylight(23.0), 0.0);
320 assert_eq!(daylight(2.0), 0.0);
321 assert_eq!(daylight(19.0), 0.5);
322 assert_eq!(daylight(6.0), 0.5);
323 }
324
325 // Building a fire takes time, so colonists start before it actually gets dark.
326 #[test]
327 fn darkness_is_anticipated_before_dusk() {
328 let at = |hour: u64| SimTick((hour + 24 - 6) % 24 * TICKS_PER_HOUR);
329 assert!(!dark_soon(at(12)));
330 assert!(dark_soon(at(18)));
331 assert!(dark_soon(at(23)));
332 assert!(!dark_soon(at(8)));
333 }
334
335 #[test]
336 fn fires_light_their_surroundings_with_falloff() {
337 let fire = UVec2::new(10, 10);
338 let near = StructureKind::Campfire.light_at(fire, UVec2::new(11, 10));
339 let far = StructureKind::Campfire.light_at(fire, UVec2::new(15, 10));
340 assert!(brightness(near) > brightness(far));
341 assert_eq!(
342 StructureKind::Campfire.light_at(fire, UVec2::new(17, 10)),
343 Vec3::ZERO
344 );
345 }
346
347 #[test]
348 fn light_map_combines_ambient_and_fires() {
349 let fire = (UVec2::new(5, 5), StructureKind::Campfire);
350 let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]);
351 assert!(night.is_lit(UVec2::new(5, 6)));
352 assert!(!night.is_lit(UVec2::new(18, 18)));
353 let day = LightMap::compute(UVec2::splat(20), 1.0, []);
354 assert!(day.is_lit(UVec2::new(18, 18)));
355 }
356
357 #[test]
358 fn an_empty_light_map_counts_as_daylight() {
359 assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
360 }
361
362 #[test]
363 fn structures_burn_out() {
364 let mut app = App::new();
365 app.add_systems(Update, burn_fuel);
366 let fire = app
367 .world_mut()
368 .spawn(Structure {
369 kind: StructureKind::Torch,
370 fuel: 3,
371 })
372 .id();
373 for _ in 0..3 {
374 app.update();
375 }
376 assert!(app.world().get_entity(fire).is_err());
377 }
378 }