src/tilemap.rs 21.4 K · 658 lines · raw · history

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