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