src/tilemap.rs 4.3 K · 134 lines · raw · history

1 use bevy::{
2 asset::RenderAssetUsages,
3 prelude::*,
4 render::render_resource::{Extent3d, TextureDimension, TextureFormat},
5 sprite_render::{TileData, TilemapChunk, TilemapChunkTileData},
6 };
7
8 use crate::world::{hash2, Terrain, WorldMap, TILE_SIZE};
9
10 pub const CHUNK_SIZE: u32 = 64;
11 const TEXTURE_SIZE: u32 = 16;
12
13 pub struct TilemapPlugin;
14
15 impl Plugin for TilemapPlugin {
16 fn build(&self, app: &mut App) {
17 app.add_systems(Startup, spawn_chunks);
18 }
19 }
20
21 fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) {
22 let tileset = images.add(build_tileset());
23 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
24
25 for cy in 0..chunks.y {
26 for cx in 0..chunks.x {
27 let origin = UVec2::new(cx, cy) * CHUNK_SIZE;
28 let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE;
29 commands.spawn((
30 Name::new(format!("Chunk {cx},{cy}")),
31 TilemapChunk {
32 chunk_size: UVec2::splat(CHUNK_SIZE),
33 tile_display_size: UVec2::splat(TILE_SIZE as u32),
34 tileset: tileset.clone(),
35 ..default()
36 },
37 TilemapChunkTileData(chunk_tiles(&map, origin)),
38 Transform::from_translation(center.extend(0.0)),
39 ));
40 }
41 }
42 }
43
44 pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
45 (0..CHUNK_SIZE)
46 .flat_map(|y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
47 .map(|pos| {
48 map.get(pos.as_ivec2()).map(|terrain| TileData {
49 tileset_index: terrain.index(),
50 color: tile_tint(pos),
51 ..default()
52 })
53 })
54 .collect()
55 }
56
57 fn tile_tint(pos: UVec2) -> Color {
58 let v = 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01;
59 Color::srgb(v, v, v)
60 }
61
62 pub fn tileset_layer_count(terrains: u32) -> u32 {
63 match terrains {
64 0 | 1 => 2,
65 n if n % 6 == 0 => n + 1,
66 n => n,
67 }
68 }
69
70 fn build_tileset() -> Image {
71 let layers = tileset_layer_count(Terrain::ALL.len() as u32);
72 let data = (0..layers)
73 .flat_map(|layer| {
74 let base = Terrain::ALL
75 .get(layer as usize)
76 .map_or(Srgba::NONE, |t| t.color());
77 (0..TEXTURE_SIZE * TEXTURE_SIZE).flat_map(move |i| {
78 let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0;
79 let c = Srgba::new(base.red + jitter, base.green + jitter, base.blue + jitter, 1.0);
80 c.to_u8_array()
81 })
82 })
83 .collect();
84
85 let mut image = Image::new(
86 Extent3d {
87 width: TEXTURE_SIZE,
88 height: TEXTURE_SIZE * layers,
89 depth_or_array_layers: 1,
90 },
91 TextureDimension::D2,
92 data,
93 TextureFormat::Rgba8UnormSrgb,
94 RenderAssetUsages::RENDER_WORLD,
95 );
96 image
97 .reinterpret_stacked_2d_as_array(layers)
98 .expect("tileset is a vertical stack of square layers");
99 image
100 }
101
102 #[cfg(test)]
103 mod tests {
104 use super::*;
105
106 #[test]
107 fn chunk_tiles_beyond_map_edge_are_empty() {
108 let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone);
109 let tiles = chunk_tiles(&map, UVec2::new(CHUNK_SIZE, 0));
110 assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize);
111 assert!(tiles[0].is_some());
112 assert!(tiles[1].is_some());
113 assert!(tiles[2].is_none());
114 assert!(tiles[CHUNK_SIZE as usize * 3].is_none());
115 }
116
117 #[test]
118 fn tileset_has_a_layer_for_every_terrain() {
119 let image = build_tileset();
120 assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32);
121 }
122
123 // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count:
124 // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array,
125 // so the tileset is padded with an unused layer to dodge those counts.
126 #[test]
127 fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() {
128 for n in 0..50 {
129 let layers = tileset_layer_count(n);
130 assert!(layers >= n.max(2));
131 assert!(layers % 6 != 0, "{n} terrains -> {layers} layers");
132 }
133 }
134 }