Let walls cast shadows and roofs keep daylight out

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

author
Marijn Besseling <njirambem@gmail.com> · 2026-10-24 22:06 UTC
commit
2ac698df8f8b8e31c0ad6105a11a30b706eac255
parent
8279a8c003
tree
browse at this commit

3 files changed +59 -17

src/jobs/tests.rs +2 -2

@@ -516,8 +516,8 @@ fn work_is_slower_in_the_dark() {
516 516 let tree = UVec2::new(8, 5);
517 517 let finish_ticks = |daylight: f32| {
518 518 let mut app = test_app(map_with_trees(&[tree]));
519 - let size = app.world().resource::<WorldMap>().size;
520 - app.insert_resource(LightMap::compute(size, daylight, []));
519 + let map = app.world().resource::<WorldMap>().clone();
520 + app.insert_resource(LightMap::compute(&map, daylight, []));
521 521 spawn_colonist(&mut app, UVec2::new(7, 5));
522 522 let mut ticks = 0;
523 523 while app

src/light.rs +56 -14

@@ -3,7 +3,7 @@ use bevy::prelude::*;
3 3 use crate::{
4 4 sim::{SimSystems, SimTick, TICKS_PER_HOUR, hour_of_day},
5 5 structures::{Structure, StructureKind},
6 - world::{TilePos, WorldMap},
6 + world::{TileChanged, TilePos, WorldMap},
7 7 };
8 8
9 9 pub struct LightPlugin;
@@ -20,6 +20,7 @@ pub const LIGHT_LOOKAHEAD_TICKS: u64 = TICKS_PER_HOUR * 3 / 2;
20 20 const AMBIENT_STEPS: f32 = 16.0;
21 21 const DAY_COLOR: Vec3 = Vec3::ONE;
22 22 const NIGHT_COLOR: Vec3 = Vec3::new(0.2, 0.24, 0.4);
23 +pub const ROOF_DAYLIGHT: f32 = 0.3;
23 24
24 25 pub fn daylight(hour: f32) -> f32 {
25 26 match hour {
@@ -55,6 +56,15 @@ pub fn light_at(kind: StructureKind, source: UVec2, tile: UVec2) -> Vec3 {
55 56 color * (1.0 - d * d).powi(2)
56 57 }
57 58
59 +pub fn line_of_sight(map: &WorldMap, from: UVec2, to: UVec2) -> bool {
60 + let (a, b) = (from.as_vec2(), to.as_vec2());
61 + let steps = (b - a).abs().max_element().ceil() as i32;
62 + (1..steps).all(|i| {
63 + let p = a.lerp(b, i as f32 / steps as f32).round().as_ivec2();
64 + !map.is_wall(p)
65 + })
66 +}
67 +
58 68 pub fn light_from(tile: UVec2, sources: impl IntoIterator<Item = (UVec2, StructureKind)>) -> Vec3 {
59 69 sources
60 70 .into_iter()
@@ -67,7 +77,9 @@ pub struct LightMap {
67 77 size: UVec2,
68 78 ambient_step: u32,
69 79 ambient: Vec3,
80 + indoor_ambient: Vec3,
70 81 sources: Vec<Vec3>,
82 + roofed: Vec<bool>,
71 83 }
72 84
73 85 impl LightMap {
@@ -75,7 +87,13 @@ impl LightMap {
75 87 if self.sources.is_empty() || tile.x >= self.size.x || tile.y >= self.size.y {
76 88 return Vec3::ONE;
77 89 }
78 - (self.ambient + self.sources[(tile.y * self.size.x + tile.x) as usize]).min(Vec3::ONE)
90 + let index = (tile.y * self.size.x + tile.x) as usize;
91 + let ambient = if self.roofed[index] {
92 + self.indoor_ambient
93 + } else {
94 + self.ambient
95 + };
96 + (ambient + self.sources[index]).min(Vec3::ONE)
79 97 }
80 98
81 99 pub fn is_lit(&self, tile: UVec2) -> bool {
@@ -83,10 +101,11 @@ impl LightMap {
83 101 }
84 102
85 103 pub fn compute(
86 - size: UVec2,
104 + map: &WorldMap,
87 105 daylight: f32,
88 106 sources: impl IntoIterator<Item = (UVec2, StructureKind)>,
89 107 ) -> Self {
108 + let size = map.size;
90 109 let ambient_step = (daylight * AMBIENT_STEPS).round() as u32;
91 110 let mut light = vec![Vec3::ZERO; size.element_product() as usize];
92 111 for (pos, kind) in sources {
@@ -105,15 +124,23 @@ impl LightMap {
105 124 continue;
106 125 }
107 126 let tile = tile.as_uvec2();
108 - light[(tile.y * size.x + tile.x) as usize] += light_at(kind, pos, tile);
127 + if line_of_sight(map, pos, tile) {
128 + light[(tile.y * size.x + tile.x) as usize] += light_at(kind, pos, tile);
129 + }
109 130 }
110 131 }
111 132 }
133 + let day = ambient_step as f32 / AMBIENT_STEPS;
112 134 Self {
113 135 size,
114 136 ambient_step,
115 - ambient: ambient_color(ambient_step as f32 / AMBIENT_STEPS),
137 + ambient: ambient_color(day),
138 + indoor_ambient: ambient_color(day * ROOF_DAYLIGHT),
116 139 sources: light,
140 + roofed: map
141 + .positions()
142 + .map(|p| map.is_roofed(p.as_ivec2()))
143 + .collect(),
117 144 }
118 145 }
119 146 }
@@ -124,25 +151,23 @@ fn update_light_map(
124 151 structures: Query<(&TilePos, &Structure)>,
125 152 added: Query<(), Added<Structure>>,
126 153 mut removed: RemovedComponents<Structure>,
154 + mut changed: MessageReader<TileChanged>,
127 155 mut light: ResMut<LightMap>,
128 156 ) {
129 157 let day = daylight(hour_of_day(*tick));
130 158 let step = (day * AMBIENT_STEPS).round() as u32;
131 - let sources_changed = !added.is_empty() || removed.read().count() > 0;
159 + let tiles_changed = changed.read().count() > 0;
160 + let sources_changed = !added.is_empty() || removed.read().count() > 0 || tiles_changed;
132 161 if !sources_changed && step == light.ambient_step && light.size == map.size {
133 162 return;
134 163 }
135 - *light = LightMap::compute(
136 - map.size,
137 - day,
138 - structures.iter().map(|(pos, s)| (pos.0, s.kind)),
139 - );
164 + *light = LightMap::compute(&map, day, structures.iter().map(|(pos, s)| (pos.0, s.kind)));
140 165 }
141 166
142 167 #[cfg(test)]
143 168 mod tests {
144 169 use super::*;
145 - use crate::sim::TICKS_PER_HOUR;
170 + use crate::{sim::TICKS_PER_HOUR, world::Terrain};
146 171
147 172 #[test]
148 173 fn days_are_bright_and_nights_dark_with_gradual_dusk_and_dawn() {
@@ -178,14 +203,31 @@ mod tests {
178 203 #[test]
179 204 fn light_map_combines_ambient_and_fires() {
180 205 let fire = (UVec2::new(5, 5), StructureKind::Campfire);
181 - let night = LightMap::compute(UVec2::splat(20), 0.0, [fire]);
206 + let map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
207 + let night = LightMap::compute(&map, 0.0, [fire]);
182 208 assert!(night.is_lit(UVec2::new(5, 6)));
183 209 assert!(!night.is_lit(UVec2::new(18, 18)));
184 - let day = LightMap::compute(UVec2::splat(20), 1.0, []);
210 + let day = LightMap::compute(&map, 1.0, []);
185 211 assert!(day.is_lit(UVec2::new(18, 18)));
186 212 }
187 213
188 214 #[test]
215 + fn walls_cast_shadows_and_roofs_keep_daylight_out() {
216 + let mut map = WorldMap::filled(UVec2::splat(20), Terrain::Grass);
217 + for y in 0..20 {
218 + map.set_wall(UVec2::new(8, y), true);
219 + }
220 + map.set_roof(UVec2::new(15, 15), true);
221 + let fire = (UVec2::new(6, 10), StructureKind::Campfire);
222 + let night = LightMap::compute(&map, 0.0, [fire]);
223 + assert!(night.is_lit(UVec2::new(7, 10)));
224 + assert!(!night.is_lit(UVec2::new(9, 10)));
225 + let day = LightMap::compute(&map, 1.0, []);
226 + assert!(day.is_lit(UVec2::new(14, 15)));
227 + assert!(!day.is_lit(UVec2::new(15, 15)));
228 + }
229 +
230 + #[test]
189 231 fn an_empty_light_map_counts_as_daylight() {
190 232 assert!(LightMap::default().is_lit(UVec2::new(3, 3)));
191 233 }

src/tilemap.rs +1 -1

@@ -501,7 +501,7 @@ mod tests {
501 501 UVec2::new(10, 10),
502 502 crate::structures::StructureKind::Campfire,
503 503 );
504 - let night = LightMap::compute(map.size, 0.0, [fire]);
504 + let night = LightMap::compute(&map, 0.0, [fire]);
505 505 let tiles = chunk_tiles(&map, &night, UVec2::ZERO);
506 506 let lum = |pos: UVec2| {
507 507 let c = tiles[(pos.y * CHUNK_SIZE + pos.x) as usize]