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