src/tilemap.rs 18.9 K · 582 lines · raw · history

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