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