src/tilemap.rs 14.1 K · 439 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 Plants,
42 }
43
44 impl Layer {
45 fn z(self) -> f32 {
46 match self {
47 Layer::Terrain => 0.0,
48 Layer::Zones => 0.3,
49 Layer::Plants => 0.5,
50 }
51 }
52
53 fn tiles(
54 self,
55 map: &WorldMap,
56 zones: &Zones,
57 light: &LightMap,
58 origin: UVec2,
59 ) -> Vec<Option<TileData>> {
60 match self {
61 Layer::Terrain => chunk_tiles(map, light, origin),
62 Layer::Zones => zone_tiles(zones, origin),
63 Layer::Plants => plant_tiles(map, light, origin),
64 }
65 }
66 }
67
68 #[derive(Component)]
69 struct Chunk {
70 origin: UVec2,
71 layer: Layer,
72 }
73
74 fn spawn_chunks(
75 mut commands: Commands,
76 map: Res<WorldMap>,
77 zones: Res<Zones>,
78 light: Res<LightMap>,
79 mut images: ResMut<Assets<Image>>,
80 ) {
81 let terrain = images.add(stack_tileset(
82 Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(),
83 ));
84 let plants = images.add(stack_tileset(
85 Plant::ALL.iter().map(|&p| plant_texture(p)).collect(),
86 ));
87 let zone = images.add(stack_tileset(vec![zone_texture()]));
88 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
89 for (layer, tileset) in [
90 (Layer::Terrain, terrain),
91 (Layer::Zones, zone),
92 (Layer::Plants, plants),
93 ] {
94 for cy in 0..chunks.y {
95 for cx in 0..chunks.x {
96 let origin = UVec2::new(cx, cy) * CHUNK_SIZE;
97 let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE;
98 commands.spawn((
99 Name::new(format!("{layer:?} chunk {cx},{cy}")),
100 Chunk { origin, layer },
101 TilemapChunk {
102 chunk_size: UVec2::splat(CHUNK_SIZE),
103 tile_display_size: UVec2::splat(TILE_SIZE as u32),
104 tileset: tileset.clone(),
105 alpha_mode: AlphaMode2d::Blend,
106 },
107 TilemapChunkTileData(layer.tiles(&map, &zones, &light, origin)),
108 Transform::from_translation(center.extend(layer.z())),
109 ));
110 }
111 }
112 }
113 }
114
115 fn refresh_chunks(
116 map: Res<WorldMap>,
117 zones: Res<Zones>,
118 light: Res<LightMap>,
119 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
120 ) {
121 for (chunk, mut data) in &mut chunks {
122 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
123 }
124 }
125
126 fn world_or_light_changed(
127 mut generated: MessageReader<WorldGenerated>,
128 light: Res<LightMap>,
129 ) -> bool {
130 generated.read().count() > 0 || light.is_changed()
131 }
132
133 fn refresh_zone_chunks(
134 map: Res<WorldMap>,
135 zones: Res<Zones>,
136 light: Res<LightMap>,
137 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
138 ) {
139 for (chunk, mut data) in &mut chunks {
140 if chunk.layer == Layer::Zones {
141 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
142 }
143 }
144 }
145
146 fn refresh_changed_chunks(
147 map: Res<WorldMap>,
148 zones: Res<Zones>,
149 light: Res<LightMap>,
150 mut changes: MessageReader<TileChanged>,
151 mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>,
152 ) {
153 let origins: Vec<UVec2> = changes
154 .read()
155 .map(|TileChanged(tile)| chunk_origin(*tile))
156 .collect();
157 for (chunk, mut data) in &mut chunks {
158 if origins.contains(&chunk.origin) {
159 data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin);
160 }
161 }
162 }
163
164 pub fn chunk_origin(tile: UVec2) -> UVec2 {
165 tile / CHUNK_SIZE * CHUNK_SIZE
166 }
167
168 fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> {
169 (0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
170 }
171
172 pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
173 chunk_positions(origin)
174 .map(|pos| {
175 map.get(pos.as_ivec2()).map(|terrain| TileData {
176 tileset_index: terrain.index(),
177 color: tint(jitter(pos) * shade(map, pos), light.color(pos)),
178 ..default()
179 })
180 })
181 .collect()
182 }
183
184 pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> {
185 chunk_positions(origin)
186 .map(|pos| {
187 zones
188 .is_stockpile(pos)
189 .then(|| TileData::from_tileset_index(0))
190 })
191 .collect()
192 }
193
194 pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> {
195 chunk_positions(origin)
196 .map(|pos| {
197 map.plant(pos.as_ivec2()).map(|plant| TileData {
198 tileset_index: plant.index(),
199 color: tint(jitter(pos) * hillshade(map, pos), light.color(pos)),
200 ..default()
201 })
202 })
203 .collect()
204 }
205
206 fn tint(v: f32, light: Vec3) -> Color {
207 Color::srgb(v * light.x, v * light.y, v * light.z)
208 }
209
210 fn jitter(pos: UVec2) -> f32 {
211 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01
212 }
213
214 fn shade(map: &WorldMap, pos: UVec2) -> f32 {
215 match map.get(pos.as_ivec2()) {
216 Some(t) if t.is_water() => 1.0,
217 _ => hillshade(map, pos) * height_brightness(map, pos),
218 }
219 }
220
221 fn height_brightness(map: &WorldMap, pos: UVec2) -> f32 {
222 0.9 + 0.2 * map.height(pos.as_ivec2()).unwrap_or(0.0) / MAX_HEIGHT
223 }
224
225 pub fn hillshade(map: &WorldMap, pos: UVec2) -> f32 {
226 let p = pos.as_ivec2();
227 let center = map.height(p).unwrap_or(0.0);
228 let h = |d: IVec2| map.height(p + d).unwrap_or(center);
229 let dx = (h(IVec2::X) - h(IVec2::NEG_X)) / 2.0;
230 let dy = (h(IVec2::Y) - h(IVec2::NEG_Y)) / 2.0;
231 let normal = Vec3::new(-dx * RELIEF, -dy * RELIEF, 1.0).normalize();
232 let light = LIGHT.normalize();
233 (normal.dot(light) / light.z).clamp(0.6, 1.25)
234 }
235
236 fn terrain_texture(terrain: Terrain) -> Texture {
237 let base = terrain.color();
238 let layer = terrain.index() as u32;
239 (0..TEXTURE_SIZE * TEXTURE_SIZE)
240 .map(|i| {
241 let speck = terrain
242 .speck_color()
243 .filter(|_| hash2(i / 2 + i / 32, layer) % 5 == 0);
244 let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0;
245 let c = speck.unwrap_or(base);
246 Srgba::new(c.red + jitter, c.green + jitter, c.blue + jitter, 1.0).to_u8_array()
247 })
248 .collect()
249 }
250
251 fn zone_texture() -> Texture {
252 (0..TEXTURE_SIZE * TEXTURE_SIZE)
253 .map(|i| {
254 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
255 let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1;
256 [90, 160, 230, if edge { 150 } else { 55 }]
257 })
258 .collect()
259 }
260
261 fn plant_texture(plant: Plant) -> Texture {
262 match plant {
263 Plant::Tree => tree_texture(),
264 }
265 }
266
267 fn tree_texture() -> Texture {
268 let leaves = Srgba::rgb_u8(38, 92, 42);
269 let highlight = Srgba::rgb_u8(66, 126, 58);
270 let trunk = Srgba::rgb_u8(92, 64, 40);
271 let canopy_center = Vec2::new(7.5, 6.5);
272 (0..TEXTURE_SIZE * TEXTURE_SIZE)
273 .map(|i| {
274 let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE);
275 let p = Vec2::new(x as f32, y as f32);
276 let from_center = p - canopy_center;
277 if from_center.length() <= 6.0 {
278 let lit = from_center.dot(Vec2::new(-1.0, -1.0)) > 3.0;
279 if lit { highlight } else { leaves }.to_u8_array()
280 } else if (7..=8).contains(&x) && y >= 12 && y <= 14 {
281 trunk.to_u8_array()
282 } else {
283 [0, 0, 0, 0]
284 }
285 })
286 .collect()
287 }
288
289 pub fn tileset_layer_count(textures: u32) -> u32 {
290 match textures {
291 0 | 1 => 2,
292 n if n % 6 == 0 => n + 1,
293 n => n,
294 }
295 }
296
297 fn stack_tileset(textures: Vec<Texture>) -> Image {
298 let layers = tileset_layer_count(textures.len() as u32);
299 let empty = vec![[0, 0, 0, 0]; (TEXTURE_SIZE * TEXTURE_SIZE) as usize];
300 let data = (0..layers as usize)
301 .flat_map(|i| textures.get(i).unwrap_or(&empty).clone())
302 .flatten()
303 .collect();
304 let mut image = Image::new(
305 Extent3d {
306 width: TEXTURE_SIZE,
307 height: TEXTURE_SIZE * layers,
308 depth_or_array_layers: 1,
309 },
310 TextureDimension::D2,
311 data,
312 TextureFormat::Rgba8UnormSrgb,
313 RenderAssetUsages::RENDER_WORLD,
314 );
315 image
316 .reinterpret_stacked_2d_as_array(layers)
317 .expect("tileset is a vertical stack of square layers");
318 image
319 }
320
321 #[cfg(test)]
322 mod tests {
323 use super::*;
324
325 #[test]
326 fn chunk_tiles_beyond_map_edge_are_empty() {
327 let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone);
328 let tiles = chunk_tiles(&map, &LightMap::default(), UVec2::new(CHUNK_SIZE, 0));
329 assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize);
330 assert!(tiles[0].is_some());
331 assert!(tiles[1].is_some());
332 assert!(tiles[2].is_none());
333 assert!(tiles[CHUNK_SIZE as usize * 3].is_none());
334 }
335
336 // Chunk entities live for the whole session. World changes (regeneration now, digging and
337 // building later) rewrite their tile data in place; TilemapChunk re-uploads it through change
338 // detection, so no entities, meshes or materials are recreated.
339 #[test]
340 fn regeneration_rewrites_tile_data_of_existing_chunks() {
341 let mut app = App::new();
342 app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass))
343 .init_resource::<Zones>()
344 .init_resource::<LightMap>()
345 .add_message::<WorldGenerated>()
346 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
347 let map = app.world().resource::<WorldMap>().clone();
348 let chunk = app
349 .world_mut()
350 .spawn((
351 Chunk {
352 origin: UVec2::ZERO,
353 layer: Layer::Terrain,
354 },
355 TilemapChunkTileData(chunk_tiles(&map, &LightMap::default(), UVec2::ZERO)),
356 ))
357 .id();
358
359 *app.world_mut().resource_mut::<WorldMap>() =
360 WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Stone);
361 app.world_mut().write_message(WorldGenerated);
362 app.update();
363
364 let data = app.world().get::<TilemapChunkTileData>(chunk).unwrap();
365 assert!(
366 data.0
367 .iter()
368 .all(|t| t.unwrap().tileset_index == Terrain::Stone.index())
369 );
370 }
371
372 #[test]
373 fn darkness_tints_tiles_and_fires_warm_them() {
374 let map = WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass);
375 let fire = (
376 UVec2::new(10, 10),
377 crate::structures::StructureKind::Campfire,
378 );
379 let night = LightMap::compute(map.size, 0.0, [fire]);
380 let tiles = chunk_tiles(&map, &night, UVec2::ZERO);
381 let lum = |pos: UVec2| {
382 let c = tiles[(pos.y * CHUNK_SIZE + pos.x) as usize]
383 .unwrap()
384 .color
385 .to_srgba();
386 c.red + c.green + c.blue
387 };
388 assert!(lum(UVec2::new(10, 11)) > lum(UVec2::new(40, 40)) * 2.0);
389 }
390
391 #[test]
392 fn chunk_origin_rounds_down_to_chunk_grid() {
393 assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0));
394 assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128));
395 }
396
397 #[test]
398 fn tileset_has_a_layer_for_every_texture() {
399 let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect();
400 let image = stack_tileset(textures);
401 assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32);
402 }
403
404 #[test]
405 fn plant_layer_only_has_tiles_where_plants_grow() {
406 let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass);
407 map.set_plant(UVec2::new(1, 0), Some(Plant::Tree));
408 let tiles = plant_tiles(&map, &LightMap::default(), UVec2::ZERO);
409 assert!(tiles[0].is_none());
410 assert_eq!(tiles[1].unwrap().tileset_index, Plant::Tree.index());
411 }
412
413 // Light comes from the north-west (LIGHT has -x, +y), the convention for hillshaded maps.
414 #[test]
415 fn slopes_facing_the_light_are_brighter_than_slopes_facing_away() {
416 let mut map = WorldMap::filled(UVec2::new(5, 1), Terrain::Grass);
417 for (x, h) in [0.0, 5.0, 10.0, 5.0, 0.0].into_iter().enumerate() {
418 map.set_height(UVec2::new(x as u32, 0), h);
419 }
420 let flat = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass);
421 let facing_west = hillshade(&map, UVec2::new(1, 0));
422 let facing_east = hillshade(&map, UVec2::new(3, 0));
423 let level = hillshade(&flat, UVec2::new(1, 0));
424 assert!(facing_west > level);
425 assert!(facing_east < level);
426 }
427
428 // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count:
429 // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array,
430 // so the tileset is padded with an unused layer to dodge those counts.
431 #[test]
432 fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() {
433 for n in 0..50 {
434 let layers = tileset_layer_count(n);
435 assert!(layers >= n.max(2));
436 assert!(layers % 6 != 0, "{n} terrains -> {layers} layers");
437 }
438 }
439 }