src/tilemap.rs 6.1 K · 187 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::{
9 world::{TILE_SIZE, Terrain, WorldMap, hash2},
10 worldgen::WorldGenerated,
11 };
12
13 pub const CHUNK_SIZE: u32 = 64;
14 const TEXTURE_SIZE: u32 = 16;
15
16 pub struct TilemapPlugin;
17
18 impl Plugin for TilemapPlugin {
19 fn build(&self, app: &mut App) {
20 app.add_systems(Startup, spawn_chunks)
21 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
22 }
23 }
24
25 #[derive(Component)]
26 struct Chunk {
27 origin: UVec2,
28 }
29
30 fn spawn_chunks(mut commands: Commands, map: Res<WorldMap>, mut images: ResMut<Assets<Image>>) {
31 let tileset = images.add(build_tileset());
32 let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE;
33 for cy in 0..chunks.y {
34 for cx in 0..chunks.x {
35 let origin = UVec2::new(cx, cy) * CHUNK_SIZE;
36 let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE;
37 commands.spawn((
38 Name::new(format!("Chunk {cx},{cy}")),
39 Chunk { origin },
40 TilemapChunk {
41 chunk_size: UVec2::splat(CHUNK_SIZE),
42 tile_display_size: UVec2::splat(TILE_SIZE as u32),
43 tileset: tileset.clone(),
44 ..default()
45 },
46 TilemapChunkTileData(chunk_tiles(&map, origin)),
47 Transform::from_translation(center.extend(0.0)),
48 ));
49 }
50 }
51 }
52
53 fn refresh_chunks(map: Res<WorldMap>, mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>) {
54 for (chunk, mut data) in &mut chunks {
55 data.0 = chunk_tiles(&map, chunk.origin);
56 }
57 }
58
59 pub fn chunk_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> {
60 (0..CHUNK_SIZE)
61 .flat_map(|y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y)))
62 .map(|pos| {
63 map.get(pos.as_ivec2()).map(|terrain| TileData {
64 tileset_index: terrain.index(),
65 color: tile_tint(pos),
66 ..default()
67 })
68 })
69 .collect()
70 }
71
72 fn tile_tint(pos: UVec2) -> Color {
73 let v = 0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01;
74 Color::srgb(v, v, v)
75 }
76
77 pub fn tileset_layer_count(terrains: u32) -> u32 {
78 match terrains {
79 0 | 1 => 2,
80 n if n % 6 == 0 => n + 1,
81 n => n,
82 }
83 }
84
85 fn build_tileset() -> Image {
86 let layers = tileset_layer_count(Terrain::ALL.len() as u32);
87 let data = (0..layers)
88 .flat_map(|layer| {
89 let base = Terrain::ALL
90 .get(layer as usize)
91 .map_or(Srgba::NONE, |t| t.color());
92 (0..TEXTURE_SIZE * TEXTURE_SIZE).flat_map(move |i| {
93 let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0;
94 let c = Srgba::new(
95 base.red + jitter,
96 base.green + jitter,
97 base.blue + jitter,
98 1.0,
99 );
100 c.to_u8_array()
101 })
102 })
103 .collect();
104
105 let mut image = Image::new(
106 Extent3d {
107 width: TEXTURE_SIZE,
108 height: TEXTURE_SIZE * layers,
109 depth_or_array_layers: 1,
110 },
111 TextureDimension::D2,
112 data,
113 TextureFormat::Rgba8UnormSrgb,
114 RenderAssetUsages::RENDER_WORLD,
115 );
116 image
117 .reinterpret_stacked_2d_as_array(layers)
118 .expect("tileset is a vertical stack of square layers");
119 image
120 }
121
122 #[cfg(test)]
123 mod tests {
124 use super::*;
125
126 #[test]
127 fn chunk_tiles_beyond_map_edge_are_empty() {
128 let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone);
129 let tiles = chunk_tiles(&map, UVec2::new(CHUNK_SIZE, 0));
130 assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize);
131 assert!(tiles[0].is_some());
132 assert!(tiles[1].is_some());
133 assert!(tiles[2].is_none());
134 assert!(tiles[CHUNK_SIZE as usize * 3].is_none());
135 }
136
137 // Chunk entities live for the whole session. World changes (regeneration now, digging and
138 // building later) rewrite their tile data in place; TilemapChunk re-uploads it through change
139 // detection, so no entities, meshes or materials are recreated.
140 #[test]
141 fn regeneration_rewrites_tile_data_of_existing_chunks() {
142 let mut app = App::new();
143 app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass))
144 .add_message::<WorldGenerated>()
145 .add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>));
146 let map = app.world().resource::<WorldMap>().clone();
147 let chunk = app
148 .world_mut()
149 .spawn((
150 Chunk {
151 origin: UVec2::ZERO,
152 },
153 TilemapChunkTileData(chunk_tiles(&map, UVec2::ZERO)),
154 ))
155 .id();
156
157 *app.world_mut().resource_mut::<WorldMap>() =
158 WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Stone);
159 app.world_mut().write_message(WorldGenerated);
160 app.update();
161
162 let data = app.world().get::<TilemapChunkTileData>(chunk).unwrap();
163 assert!(
164 data.0
165 .iter()
166 .all(|t| t.unwrap().tileset_index == Terrain::Stone.index())
167 );
168 }
169
170 #[test]
171 fn tileset_has_a_layer_for_every_terrain() {
172 let image = build_tileset();
173 assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32);
174 }
175
176 // wgpu's GLES backend (WebGL2) guesses the texture view dimension from the layer count:
177 // 1 layer => D2, 6 => Cube, multiples of 6 => CubeArray. The tilemap shader needs D2Array,
178 // so the tileset is padded with an unused layer to dodge those counts.
179 #[test]
180 fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() {
181 for n in 0..50 {
182 let layers = tileset_layer_count(n);
183 assert!(layers >= n.max(2));
184 assert!(layers % 6 != 0, "{n} terrains -> {layers} layers");
185 }
186 }
187 }