src/tilemap.rs 24.3 K · 740 lines · raw · history

1 use std::time::Duration;
2
3 use bevy::{
4 asset::RenderAssetUsages,
5 image::ImageSampler,
6 prelude::*,
7 render::render_resource::{Extent3d, TextureDimension, TextureFormat},
8 sprite_render::{AlphaMode2d, TileData, TilemapChunk, TilemapChunkTileData},
9 };
10
11 use crate::{
12 light::LightMap,
13 world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2},
14 worldgen::WorldGenerated,
15 zones::Zones,
16 };
17
18 pub const CHUNK_SIZE: u32 = 64;
19 const TEXTURE_SIZE: u32 = 16;
20 const RELIEF: f32 = 0.35;
21 const LIGHT: Vec3 = Vec3::new(-1.0, 1.0, 1.5);
22 const SHADE_FLAT: f32 = 1.1;
23 const MAX_SHADOW: f32 = 0.5;
24 const HILL_SHADE_Z: f32 = 0.1;
25 const LIGHT_REFRESH_INTERVAL: Duration = Duration::from_millis(100);
26
27 type Texture = Vec<[u8; 4]>;
28
29 pub struct TilemapPlugin;
30
31 impl Plugin for TilemapPlugin {
32 fn build(&self, app: &mut App) {
33 app.add_systems(Startup, spawn_chunks).add_systems(
34 Update,
35 (
36 refresh_chunks.run_if(world_or_light_changed),
37 refresh_changed_chunks,
38 refresh_zone_chunks.run_if(resource_changed::<Zones>),
39 refresh_hill_shade.run_if(on_message::<WorldGenerated>),
40 ),
41 );
42 }
43 }
44
45 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
46 enum Layer {
47 Terrain,
48 Snow,
49 Zones,
50 Roofs,
51 Plants,
52 }
53
54 impl Layer {
55 fn z(self) -> f32 {
56 match self {
57 Layer::Terrain => 0.0,
58 Layer::Snow => 0.2,
59 Layer::Zones => 0.3,
60 Layer::Roofs => 0.85,
61 Layer::Plants => 0.5,
62 }
63 }
64
65 fn tiles(
66 self,
67 map: &WorldMap,
68 zones: &Zones,
69 light: &LightMap,
70 origin: UVec2,
71 ) -> Vec<Option<TileData>> {
72 match self {
73 Layer::Terrain => chunk_tiles(map, light, origin),
74 Layer::Snow => snow_tiles(map, light, origin),
75 Layer::Zones => zone_tiles(zones, origin),
76 Layer::Roofs => roof_tiles(map, origin),
77 Layer::Plants => plant_tiles(map, light, origin),
78 }
79 }
80 }
81
82 #[derive(Component)]
83 struct Chunk {
84 origin: UVec2,
85 layer: Layer,
86 }
87
88 #[derive(Component)]
89 struct HillShade;
90
91 pub fn shadow_alpha(shade: f32) -> u8 {
92 ((SHADE_FLAT - shade).clamp(0.0, MAX_SHADOW) * 255.0) as u8
93 }
94
95 fn hill_shade_image(map: &WorldMap) -> Image {
96 let mut data = Vec::with_capacity((map.size.element_product() * 4) as usize);
97 for row in 0..map.size.y {
98 let y = map.size.y - 1 - row;
99 for x in 0..map.size.x {
100 let pos = UVec2::new(x, y);
101 let water = map.get(pos.as_ivec2()).is_some_and(|t| t.is_water());
102 let alpha = if water {
103 0
104 } else {
105 shadow_alpha(hillshade(map, pos))
106 };
107 data.extend_from_slice(&[0, 0, 0, alpha]);
108 }
109 }
110 let mut image = Image::new(
111 Extent3d {
112 width: map.size.x,
113 height: map.size.y,
114 depth_or_array_layers: 1,
115 },
116 TextureDimension::D2,
117 data,
118 TextureFormat::Rgba8UnormSrgb,
119 RenderAssetUsages::default(),
120 );
121 image.sampler = ImageSampler::linear();
122 image
123 }
124
125 fn refresh_hill_shade(
126 map: Res<WorldMap>,
127 shade: Single<&MeshMaterial2d<ColorMaterial>, With<HillShade>>,
128 materials: Res<Assets<ColorMaterial>>,
129 mut images: ResMut<Assets<Image>>,
130 ) {
131 if let Some(texture) = materials.get(&shade.0).and_then(|m| m.texture.clone()) {
132 let _ = images.insert(&texture, hill_shade_image(&map));
133 }
134 }
135
136 #[allow(clippy::too_many_arguments)]
137 fn spawn_chunks(
138 mut commands: Commands,
139 map: Res<WorldMap>,
140 zones: Res<Zones>,
141 light: Res<LightMap>,
142 mut images: ResMut<Assets<Image>>,
143 mut meshes: ResMut<Assets<Mesh>>,
144 mut materials: ResMut<Assets<ColorMaterial>>,
145 ) {
146 let shade = images.add(hill_shade_image(&map));
147 commands.spawn((
148 HillShade,
149 Name::new("Hill shade"),
150 Mesh2d(meshes.add(Rectangle::from_size(map.world_size()))),
151 MeshMaterial2d(materials.add(ColorMaterial {
152 texture: Some(shade),
153 ..default()
154 })),
155 Transform::from_translation((map.world_size() / 2.0).extend(HILL_SHADE_Z)),
156 ));
157 let terrain = images.add(stack_tileset(
158 Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(),
159 ));
160 let plants = images.add(stack_tileset(
161 PlantLook::ALL
162 .iter()
163 .map(|&look| plant_texture(look))
164 .collect(),
165 ));
166 let zone = images.add(stack_tileset(vec![zone_texture(), farm_texture()]));
167 let roof = images.add(stack_tileset(vec![roof_texture()]));
168 let snow = images.add(stack_tileset(vec![snow_texture()]));
169 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
170 for (layer, tileset) in [
171 (Layer::Terrain, terrain),
172 (Layer::Snow, snow),
173 (Layer::Zones, zone),
174 (Layer::Plants, plants),
175 (Layer::Roofs, roof),
176 ] {
177 for cy in 0..chunks.y {
178 for cx in 0..chunks.x {
179 let origin = UVec2::new(cx, cy) * CHUNK_SIZE;
180 let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE;
181 commands.spawn((
182 Name::new(format!("{layer:?} chunk {cx},{cy}")),
183 Chunk { origin, layer },
184 TilemapChunk {
185 chunk_size: UVec2::splat(CHUNK_SIZE),
186 tile_display_size: UVec2::splat(TILE_SIZE as u32),
187 tileset: tileset.clone(),
188 alpha_mode: AlphaMode2d::Blend,
189 },
190 TilemapChunkTileData(layer.tiles(&map, &zones, &light, origin)),
191 Transform::from_translation(center.extend(layer.z())),
192 ));
193 }
194 }
195 }
196 }
197
198 fn refresh_chunks(
199 map: Res<WorldMap>,
200 zones: Res<Zones>,
201 light: Res<LightMap>,
202 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
203 ) {
204 for (chunk, mut data) in &mut chunks {
205 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
206 }
207 }
208
209 pub fn light_refresh_due(pending: bool, since_last: Duration) -> bool {
210 pending && since_last >= LIGHT_REFRESH_INTERVAL
211 }
212
213 fn world_or_light_changed(
214 mut generated: MessageReader<WorldGenerated>,
215 light: Res<LightMap>,
216 real: Res<Time<Real>>,
217 mut pending: Local<bool>,
218 mut last: Local<Duration>,
219 ) -> bool {
220 *pending |= light.is_changed();
221 let now = real.elapsed();
222 let due = generated.read().count() > 0 || light_refresh_due(*pending, now - *last);
223 if due {
224 *pending = false;
225 *last = now;
226 }
227 due
228 }
229
230 fn refresh_zone_chunks(
231 map: Res<WorldMap>,
232 zones: Res<Zones>,
233 light: Res<LightMap>,
234 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
235 ) {
236 for (chunk, mut data) in &mut chunks {
237 if chunk.layer == Layer::Zones {
238 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
239 }
240 }
241 }
242
243 fn refresh_changed_chunks(
244 map: Res<WorldMap>,
245 zones: Res<Zones>,
246 light: Res<LightMap>,
247 mut changes: MessageReader<TileChanged>,
248 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
249 ) {
250 let origins: Vec<UVec2> = changes
251 .read()
252 .map(|TileChanged(tile)| chunk_origin(*tile))
253 .collect();
254 for (chunk, mut data) in &mut chunks {
255 if origins.contains(&chunk.origin) {
256 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
257 }
258 }
259 }
260
261 pub fn chunk_origin(tile: UVec2) -> UVec2 {
262 tile / CHUNK_SIZE * CHUNK_SIZE
263 }
264
265 fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> {
266 (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
267 }
268
269 pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
270 chunk_positions(origin)
271 .map(|pos| {
272 map.get(pos.as_ivec2()).map(|terrain| TileData {
273 tileset_index: terrain.index(),
274 color: tint(jitter(pos) * shade(map, pos), light.color(pos)),
275 ..default()
276 })
277 })
278 .collect()
279 }
280
281 pub fn snow_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
282 let cover = light.snow();
283 chunk_positions(origin)
284 .map(|pos| {
285 let p = pos.as_ivec2();
286 let land = map.get(p).is_some_and(|t| !t.is_water());
287 (cover > 0.0 && land && !map.is_roofed(p)).then(|| {
288 let l = light.color(pos);
289 TileData {
290 tileset_index: 0,
291 color: Color::srgba(l.x, l.y, l.z, cover * jitter(pos)),
292 ..default()
293 }
294 })
295 })
296 .collect()
297 }
298
299 pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
300 chunk_positions(origin)
301 .map(|pos| {
302 zones
303 .is_stockpile(pos)
304 .then(|| TileData::from_tileset_index(0))
305 })
306 .collect()
307 }
308
309 pub fn roof_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
310 chunk_positions(origin)
311 .map(|pos| {
312 map.is_roofed(pos.as_ivec2())
313 .then(|| TileData::from_tileset_index(0))
314 })
315 .collect()
316 }
317
318 pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
319 chunk_positions(origin)
320 .map(|pos| {
321 map.plant(pos.as_ivec2()).map(|plant| TileData {
322 tileset_index: PlantLook::of(plant, map.growth(pos.as_ivec2())) as u16,
323 color: tint(jitter(pos), light.color(pos)),
324 ..default()
325 })
326 })
327 .collect()
328 }
329
330 fn tint(v: f32, light: Vec3) -> Color {
331 Color::srgb(v * light.x, v * light.y, v * light.z)
332 }
333
334 fn jitter(pos: UVec2) -> f32 {
335 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01
336 }
337
338 fn shade(map: &WorldMap, pos: UVec2) -> f32 {
339 match map.get(pos.as_ivec2()) {
340 Some(t) if t.is_water() => 1.0,
341 _ => SHADE_FLAT * height_brightness(map, pos),
342 }
343 }
344
345 fn height_brightness(map: &WorldMap, pos: UVec2) -> f32 {
346 0.9 + 0.2 * map.height(pos.as_ivec2()).unwrap_or(0.0) / MAX_HEIGHT
347 }
348
349 pub fn hillshade(map: &WorldMap, pos: UVec2) -> f32 {
350 let p = pos.as_ivec2();
351 let center = map.height(p).unwrap_or(0.0);
352 let h = |d: IVec2| map.height(p + d).unwrap_or(center);
353 let dx = (h(IVec2::X) - h(IVec2::NEG_X)) / 2.0;
354 let dy = (h(IVec2::Y) - h(IVec2::NEG_Y)) / 2.0;
355 let normal = Vec3::new(-dx * RELIEF, -dy * RELIEF, 1.0).normalize();
356 let light = LIGHT.normalize();
357 (normal.dot(light) / light.z).clamp(0.6, 1.25)
358 }
359
360 fn terrain_texture(terrain: Terrain) -> Texture {
361 let base = terrain.color();
362 let layer = terrain.index() as u32;
363 (0..TEXTURE_SIZE * TEXTURE_SIZE)
364 .map(|i| {
365 let speck = terrain
366 .speck_color()
367 .filter(|_| hash2(i / 2 + i / 32, layer) % 5 == 0);
368 let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0;
369 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
370 let mortar = terrain == Terrain::Paved
371 && (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0);
372 let seam = terrain == Terrain::WoodFloor && (y % 4 == 0 || (x + (y / 4) * 5) % 16 == 0);
373 let rail = terrain == Terrain::Bridge && (x <= 1 || x >= TEXTURE_SIZE - 2);
374 let gap = terrain == Terrain::Bridge && y % 4 == 0;
375 let c = if mortar {
376 Srgba::rgb_u8(110, 106, 100)
377 } else if seam {
378 Srgba::rgb_u8(100, 70, 42)
379 } else if rail {
380 Srgba::rgb_u8(80, 56, 34)
381 } else if gap {
382 Terrain::ShallowWater.color()
383 } else {
384 speck.unwrap_or(base)
385 };
386 Srgba::new(c.red + jitter, c.green + jitter, c.blue + jitter, 1.0).to_u8_array()
387 })
388 .collect()
389 }
390
391 fn zone_texture() -> Texture {
392 (0..TEXTURE_SIZE * TEXTURE_SIZE)
393 .map(|i| {
394 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
395 let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1;
396 [90, 160, 230, if edge { 150 } else { 55 }]
397 })
398 .collect()
399 }
400
401 fn snow_texture() -> Texture {
402 (0..TEXTURE_SIZE * TEXTURE_SIZE)
403 .map(|i| {
404 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
405 let speck = hash2(x, y + 7) % 7 == 0;
406 if speck {
407 [215, 225, 240, 255]
408 } else {
409 [245, 248, 255, 255]
410 }
411 })
412 .collect()
413 }
414
415 fn roof_texture() -> Texture {
416 (0..TEXTURE_SIZE * TEXTURE_SIZE)
417 .map(|i| {
418 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
419 let stripe = (x + y) % 4 == 0;
420 [110, 80, 50, if stripe { 90 } else { 45 }]
421 })
422 .collect()
423 }
424
425 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
426 pub enum PlantLook {
427 Tree,
428 Bush,
429 RipeBush,
430 Sprout,
431 YoungCrop,
432 RipeCrop,
433 Herb,
434 RipeHerb,
435 }
436
437 impl PlantLook {
438 const ALL: [PlantLook; 8] = [
439 PlantLook::Tree,
440 PlantLook::Bush,
441 PlantLook::RipeBush,
442 PlantLook::Sprout,
443 PlantLook::YoungCrop,
444 PlantLook::RipeCrop,
445 PlantLook::Herb,
446 PlantLook::RipeHerb,
447 ];
448
449 pub fn of(plant: Plant, growth: f32) -> Self {
450 match plant {
451 Plant::Tree => PlantLook::Tree,
452 Plant::BerryBush if plant.is_ripe(growth) => PlantLook::RipeBush,
453 Plant::BerryBush => PlantLook::Bush,
454 Plant::Crop if plant.is_ripe(growth) => PlantLook::RipeCrop,
455 Plant::Crop if growth >= SPROUT_UNTIL => PlantLook::YoungCrop,
456 Plant::Crop => PlantLook::Sprout,
457 Plant::Herb if plant.is_ripe(growth) => PlantLook::RipeHerb,
458 Plant::Herb => PlantLook::Herb,
459 }
460 }
461 }
462
463 pub const SPROUT_UNTIL: f32 = 0.4;
464
465 fn farm_texture() -> Texture {
466 (0..TEXTURE_SIZE * TEXTURE_SIZE)
467 .map(|i| {
468 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
469 let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1;
470 let furrow = x % 4 == 0;
471 match (edge, furrow) {
472 (true, _) => [150, 190, 90, 140],
473 (_, true) => [80, 56, 34, 150],
474 _ => [110, 78, 48, 110],
475 }
476 })
477 .collect()
478 }
479
480 fn plant_texture(look: PlantLook) -> Texture {
481 let leaves = Srgba::rgb_u8(52, 110, 50);
482 let berry = Srgba::rgb_u8(190, 40, 90);
483 let stalk = Srgba::rgb_u8(110, 160, 60);
484 let grain = Srgba::rgb_u8(220, 190, 80);
485 let herb = Srgba::rgb_u8(90, 150, 110);
486 let bloom = Srgba::rgb_u8(200, 170, 240);
487 match look {
488 PlantLook::Tree => tree_texture(),
489 PlantLook::Bush => blob_texture(5.0, leaves, None),
490 PlantLook::RipeBush => blob_texture(5.0, leaves, Some(berry)),
491 PlantLook::Sprout => rows_texture(3, stalk, None),
492 PlantLook::YoungCrop => rows_texture(7, stalk, None),
493 PlantLook::RipeCrop => rows_texture(10, stalk, Some(grain)),
494 PlantLook::Herb => blob_texture(3.0, herb, None),
495 PlantLook::RipeHerb => blob_texture(3.5, herb, Some(bloom)),
496 }
497 }
498
499 fn blob_texture(radius: f32, color: Srgba, dots: Option<Srgba>) -> Texture {
500 (0..TEXTURE_SIZE * TEXTURE_SIZE)
501 .map(|i| {
502 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
503 let d = Vec2::new(x as f32, y as f32).distance(Vec2::new(7.5, 8.5));
504 if d > radius {
505 return [0, 0, 0, 0];
506 }
507 match dots {
508 Some(dot) if hash2(x, y) % 5 == 0 => dot.to_u8_array(),
509 _ => color.to_u8_array(),
510 }
511 })
512 .collect()
513 }
514
515 fn rows_texture(height: u32, stalk: Srgba, head: Option<Srgba>) -> Texture {
516 (0..TEXTURE_SIZE * TEXTURE_SIZE)
517 .map(|i| {
518 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
519 let in_row = x % 4 == 1 || x % 4 == 2;
520 let top = TEXTURE_SIZE - 2 - height;
521 if !in_row || y < top || y >= TEXTURE_SIZE - 2 {
522 return [0, 0, 0, 0];
523 }
524 match head {
525 Some(head) if y < top + 3 => head.to_u8_array(),
526 _ => stalk.to_u8_array(),
527 }
528 })
529 .collect()
530 }
531
532 fn tree_texture() -> Texture {
533 let leaves = Srgba::rgb_u8(38, 92, 42);
534 let highlight = Srgba::rgb_u8(66, 126, 58);
535 let trunk = Srgba::rgb_u8(92, 64, 40);
536 let canopy_center = Vec2::new(7.5, 6.5);
537 (0..TEXTURE_SIZE * TEXTURE_SIZE)
538 .map(|i| {
539 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
540 let p = Vec2::new(x as f32, y as f32);
541 let from_center = p - canopy_center;
542 if from_center.length() <= 6.0 {
543 let lit = from_center.dot(Vec2::new(-1.0, -1.0)) > 3.0;
544 if lit { highlight } else { leaves }.to_u8_array()
545 } else if (7..=8).contains(&x) && y >= 12 && y <= 14 {
546 trunk.to_u8_array()
547 } else {
548 [0, 0, 0, 0]
549 }
550 })
551 .collect()
552 }
553
554 pub fn tileset_layer_count(textures: u32) -> u32 {
555 match textures {
556 0 | 1 => 2,
557 n if n % 6 == 0 => n + 1,
558 n => n,
559 }
560 }
561
562 fn stack_tileset(textures: Vec<Texture>) -> Image {
563 let layers = tileset_layer_count(textures.len() as u32);
564 let empty = vec![[0, 0, 0, 0]; (TEXTURE_SIZE * TEXTURE_SIZE) as usize];
565 let data = (0..layers as usize)
566 .flat_map(|i| textures.get(i).unwrap_or(&empty).clone())
567 .flatten()
568 .collect();
569 let mut image = Image::new(
570 Extent3d {
571 width: TEXTURE_SIZE,
572 height: TEXTURE_SIZE * layers,
573 depth_or_array_layers: 1,
574 },
575 TextureDimension::D2,
576 data,
577 TextureFormat::Rgba8UnormSrgb,
578 RenderAssetUsages::RENDER_WORLD,
579 );
580 image
581 .reinterpret_stacked_2d_as_array(layers)
582 .expect("tileset is a vertical stack of square layers");
583 image
584 }
585
586 #[cfg(test)]
587 mod tests {
588 use super::*;
589
590 #[test]
591 fn chunk_tiles_beyond_map_edge_are_empty() {
592 let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone);
593 let tiles = chunk_tiles(&map, &LightMap::default(), UVec2::new(CHUNK_SIZE, 0));
594 assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize);
595 assert!(tiles[0].is_some());
596 assert!(tiles[1].is_some());
597 assert!(tiles[2].is_none());
598 assert!(tiles[CHUNK_SIZE as usize * 3].is_none());
599 }
600
601 // Chunk entities live for the whole session. World changes (regeneration now, digging and
602 // building later) rewrite their tile data in place; TilemapChunk re-uploads it through change
603 // detection, so no entities, meshes or materials are recreated.
604 #[test]
605 fn regeneration_rewrites_tile_data_of_existing_chunks() {
606 let mut app = App::new();
607 app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass))
608 .init_resource::<Zones>()
609 .init_resource::<LightMap>()
610 .add_message::<WorldGenerated>()
611 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
612 let map = app.world().resource::<WorldMap>().clone();
613 let chunk = app
614 .world_mut()
615 .spawn((
616 Chunk {
617 origin: UVec2::ZERO,
618 layer: Layer::Terrain,
619 },
620 TilemapChunkTileData(chunk_tiles(&map, &LightMap::default(), UVec2::ZERO)),
621 ))
622 .id();
623
624 *app.world_mut().resource_mut::<WorldMap>() =
625 WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Stone);
626 app.world_mut().write_message(WorldGenerated);
627 app.update();
628
629 let data = app.world().get::<TilemapChunkTileData>(chunk).unwrap();
630 assert!(
631 data.0
632 .iter()
633 .all(|t| t.unwrap().tileset_index == Terrain::Stone.index())
634 );
635 }
636
637 #[test]
638 fn darkness_tints_tiles_and_fires_warm_them() {
639 let map = WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass);
640 let fire = (
641 UVec2::new(10, 10),
642 crate::structures::StructureKind::Campfire,
643 );
644 let night = LightMap::compute(&map, 0.0, [fire]);
645 let tiles = chunk_tiles(&map, &night, UVec2::ZERO);
646 let lum = |pos: UVec2| {
647 let c = tiles[(pos.y * CHUNK_SIZE + pos.x) as usize]
648 .unwrap()
649 .color
650 .to_srgba();
651 c.red + c.green + c.blue
652 };
653 assert!(lum(UVec2::new(10, 11)) > lum(UVec2::new(40, 40)) * 2.0);
654 }
655
656 #[test]
657 fn roof_layer_covers_roofed_tiles_only() {
658 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
659 map.set_roof(UVec2::new(2, 1), true);
660 let tiles = roof_tiles(&map, UVec2::ZERO);
661 assert!(tiles[(CHUNK_SIZE + 2) as usize].is_some());
662 assert_eq!(tiles.iter().filter(|t| t.is_some()).count(), 1);
663 }
664
665 #[test]
666 fn chunk_origin_rounds_down_to_chunk_grid() {
667 assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0));
668 assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128));
669 }
670
671 #[test]
672 fn tileset_has_a_layer_for_every_texture() {
673 let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect();
674 let image = stack_tileset(textures);
675 assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32);
676 }
677
678 #[test]
679 fn plants_look_different_as_they_grow() {
680 assert_eq!(PlantLook::of(Plant::Crop, 0.1), PlantLook::Sprout);
681 assert_eq!(PlantLook::of(Plant::Crop, 0.7), PlantLook::YoungCrop);
682 assert_eq!(PlantLook::of(Plant::Crop, 1.0), PlantLook::RipeCrop);
683 assert_eq!(PlantLook::of(Plant::BerryBush, 0.5), PlantLook::Bush);
684 assert_eq!(PlantLook::of(Plant::BerryBush, 1.0), PlantLook::RipeBush);
685 }
686
687 #[test]
688 fn plant_layer_only_has_tiles_where_plants_grow() {
689 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
690 map.set_plant(UVec2::new(1, 0), Some(Plant::Tree));
691 let tiles = plant_tiles(&map, &LightMap::default(), UVec2::ZERO);
692 assert!(tiles[0].is_none());
693 assert_eq!(tiles[1].unwrap().tileset_index, PlantLook::Tree as u16);
694 }
695
696 // Light comes from the north-west (LIGHT has -x, +y), the convention for hillshaded maps.
697 #[test]
698 fn slopes_facing_the_light_are_brighter_than_slopes_facing_away() {
699 let mut map = WorldMap::filled(UVec2::new(5, 1), Terrain::Grass);
700 for (x, h) in [0.0, 5.0, 10.0, 5.0, 0.0].into_iter().enumerate() {
701 map.set_height(UVec2::new(x as u32, 0), h);
702 }
703 let flat = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass);
704 let facing_west = hillshade(&map, UVec2::new(1, 0));
705 let facing_east = hillshade(&map, UVec2::new(3, 0));
706 let level = hillshade(&flat, UVec2::new(1, 0));
707 assert!(facing_west > level);
708 assert!(facing_east < level);
709 }
710
711 // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count:
712 // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array,
713 // so the tileset is padded with an unused layer to dodge those counts.
714 // Shading is drawn as a smooth, linearly filtered overlay instead of per-tile tints, which
715 // looked blocky up close; slopes facing away from the light get the darkest shadow.
716 #[test]
717 fn shadows_darken_slopes_facing_away_from_the_light() {
718 assert_eq!(shadow_alpha(1.25), 0);
719 assert!(shadow_alpha(0.7) > shadow_alpha(1.0));
720 assert_eq!(shadow_alpha(0.0), (MAX_SHADOW * 255.0) as u8);
721 }
722
723 // Every ambient light step re-tints all chunks; at high sim speeds dusk produced one per
724 // frame and dragged the frame rate down, so light-driven refreshes are rate limited.
725 #[test]
726 fn light_refreshes_are_rate_limited() {
727 assert!(!light_refresh_due(true, Duration::from_millis(20)));
728 assert!(light_refresh_due(true, LIGHT_REFRESH_INTERVAL));
729 assert!(!light_refresh_due(false, Duration::from_secs(5)));
730 }
731
732 #[test]
733 fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() {
734 for n in 0..50 {
735 let layers = tileset_layer_count(n);
736 assert!(layers >= n.max(2));
737 assert!(layers % 6 != 0, "{n} terrains -> {layers} layers");
738 }
739 }
740 }