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