| 1 |
use std::time::Duration; |
| 2 |
|
| 3 |
use bevy::{ |
| 4 |
asset::RenderAssetUsages, |
| 5 |
image::ImageSampler, |
| 6 |
prelude::*, |
| 7 |
render::render_resource::{Extent3d, TextureDimension, TextureFormat}, |
| 8 |
sprite_render::{AlphaMode2d, TileData, TilemapChunk, TilemapChunkTileData}, |
| 9 |
}; |
| 10 |
|
| 11 |
use crate::{ |
| 12 |
light::LightMap, |
| 13 |
world::{MAX_HEIGHT, Plant, TILE_SIZE, Terrain, TileChanged, WorldMap, hash2}, |
| 14 |
worldgen::WorldGenerated, |
| 15 |
zones::Zones, |
| 16 |
}; |
| 17 |
|
| 18 |
pub const CHUNK_SIZE: u32 = 64; |
| 19 |
const TEXTURE_SIZE: u32 = 16; |
| 20 |
const RELIEF: f32 = 0.35; |
| 21 |
const LIGHT: Vec3 = Vec3::new(-1.0, 1.0, 1.5); |
| 22 |
const SHADE_FLAT: f32 = 1.1; |
| 23 |
const MAX_SHADOW: f32 = 0.5; |
| 24 |
const HILL_SHADE_Z: f32 = 0.1; |
| 25 |
const LIGHT_REFRESH_INTERVAL: Duration = Duration::from_millis(100); |
| 26 |
|
| 27 |
type Texture = Vec<[u8; 4]>; |
| 28 |
|
| 29 |
pub struct TilemapPlugin; |
| 30 |
|
| 31 |
impl Plugin for TilemapPlugin { |
| 32 |
fn build(&self, app: &mut App) { |
| 33 |
app.add_systems(Startup, spawn_chunks).add_systems( |
| 34 |
Update, |
| 35 |
( |
| 36 |
refresh_chunks.run_if(world_or_light_changed), |
| 37 |
refresh_changed_chunks, |
| 38 |
refresh_zone_chunks.run_if(resource_changed::<Zones>), |
| 39 |
refresh_hill_shade.run_if(on_message::<WorldGenerated>), |
| 40 |
), |
| 41 |
); |
| 42 |
} |
| 43 |
} |
| 44 |
|
| 45 |
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 46 |
enum Layer { |
| 47 |
Terrain, |
| 48 |
Snow, |
| 49 |
Zones, |
| 50 |
Roofs, |
| 51 |
Plants, |
| 52 |
} |
| 53 |
|
| 54 |
impl Layer { |
| 55 |
fn z(self) -> f32 { |
| 56 |
match self { |
| 57 |
Layer::Terrain => 0.0, |
| 58 |
Layer::Snow => 0.2, |
| 59 |
Layer::Zones => 0.3, |
| 60 |
Layer::Roofs => 0.85, |
| 61 |
Layer::Plants => 0.5, |
| 62 |
} |
| 63 |
} |
| 64 |
|
| 65 |
fn tiles( |
| 66 |
self, |
| 67 |
map: &WorldMap, |
| 68 |
zones: &Zones, |
| 69 |
light: &LightMap, |
| 70 |
origin: UVec2, |
| 71 |
) -> Vec<Option<TileData>> { |
| 72 |
match self { |
| 73 |
Layer::Terrain => chunk_tiles(map, light, origin), |
| 74 |
Layer::Snow => snow_tiles(map, light, origin), |
| 75 |
Layer::Zones => zone_tiles(zones, origin), |
| 76 |
Layer::Roofs => roof_tiles(map, origin), |
| 77 |
Layer::Plants => plant_tiles(map, light, origin), |
| 78 |
} |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
#[derive(Component)] |
| 83 |
struct Chunk { |
| 84 |
origin: UVec2, |
| 85 |
layer: Layer, |
| 86 |
} |
| 87 |
|
| 88 |
#[derive(Component)] |
| 89 |
struct HillShade; |
| 90 |
|
| 91 |
pub fn shadow_alpha(shade: f32) -> u8 { |
| 92 |
((SHADE_FLAT - shade).clamp(0.0, MAX_SHADOW) * 255.0) as u8 |
| 93 |
} |
| 94 |
|
| 95 |
fn hill_shade_image(map: &WorldMap) -> Image { |
| 96 |
let mut data = Vec::with_capacity((map.size.element_product() * 4) as usize); |
| 97 |
for row in 0..map.size.y { |
| 98 |
let y = map.size.y - 1 - row; |
| 99 |
for x in 0..map.size.x { |
| 100 |
let pos = UVec2::new(x, y); |
| 101 |
let water = map.get(pos.as_ivec2()).is_some_and(|t| t.is_water()); |
| 102 |
let alpha = if water { |
| 103 |
0 |
| 104 |
} else { |
| 105 |
shadow_alpha(hillshade(map, pos)) |
| 106 |
}; |
| 107 |
data.extend_from_slice(&[0, 0, 0, alpha]); |
| 108 |
} |
| 109 |
} |
| 110 |
let mut image = Image::new( |
| 111 |
Extent3d { |
| 112 |
width: map.size.x, |
| 113 |
height: map.size.y, |
| 114 |
depth_or_array_layers: 1, |
| 115 |
}, |
| 116 |
TextureDimension::D2, |
| 117 |
data, |
| 118 |
TextureFormat::Rgba8UnormSrgb, |
| 119 |
RenderAssetUsages::default(), |
| 120 |
); |
| 121 |
image.sampler = ImageSampler::linear(); |
| 122 |
image |
| 123 |
} |
| 124 |
|
| 125 |
fn refresh_hill_shade( |
| 126 |
map: Res<WorldMap>, |
| 127 |
shade: Single<&MeshMaterial2d<ColorMaterial>, With<HillShade>>, |
| 128 |
materials: Res<Assets<ColorMaterial>>, |
| 129 |
mut images: ResMut<Assets<Image>>, |
| 130 |
) { |
| 131 |
if let Some(texture) = materials.get(&shade.0).and_then(|m| m.texture.clone()) { |
| 132 |
let _ = images.insert(&texture, hill_shade_image(&map)); |
| 133 |
} |
| 134 |
} |
| 135 |
|
| 136 |
#[allow(clippy::too_many_arguments)] |
| 137 |
fn spawn_chunks( |
| 138 |
mut commands: Commands, |
| 139 |
map: Res<WorldMap>, |
| 140 |
zones: Res<Zones>, |
| 141 |
light: Res<LightMap>, |
| 142 |
mut images: ResMut<Assets<Image>>, |
| 143 |
mut meshes: ResMut<Assets<Mesh>>, |
| 144 |
mut materials: ResMut<Assets<ColorMaterial>>, |
| 145 |
) { |
| 146 |
let shade = images.add(hill_shade_image(&map)); |
| 147 |
commands.spawn(( |
| 148 |
HillShade, |
| 149 |
Name::new("Hill shade"), |
| 150 |
Mesh2d(meshes.add(Rectangle::from_size(map.world_size()))), |
| 151 |
MeshMaterial2d(materials.add(ColorMaterial { |
| 152 |
texture: Some(shade), |
| 153 |
..default() |
| 154 |
})), |
| 155 |
Transform::from_translation((map.world_size() / 2.0).extend(HILL_SHADE_Z)), |
| 156 |
)); |
| 157 |
let terrain = images.add(stack_tileset( |
| 158 |
Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(), |
| 159 |
)); |
| 160 |
let plants = images.add(stack_tileset( |
| 161 |
PlantLook::ALL |
| 162 |
.iter() |
| 163 |
.map(|&look| plant_texture(look)) |
| 164 |
.collect(), |
| 165 |
)); |
| 166 |
let zone = images.add(stack_tileset(vec![zone_texture(), farm_texture()])); |
| 167 |
let roof = images.add(stack_tileset(vec![roof_texture()])); |
| 168 |
let snow = images.add(stack_tileset(vec![snow_texture()])); |
| 169 |
let chunks = (map.size + CHUNK_SIZE - 1) / CHUNK_SIZE; |
| 170 |
for (layer, tileset) in [ |
| 171 |
(Layer::Terrain, terrain), |
| 172 |
(Layer::Snow, snow), |
| 173 |
(Layer::Zones, zone), |
| 174 |
(Layer::Plants, plants), |
| 175 |
(Layer::Roofs, roof), |
| 176 |
] { |
| 177 |
for cy in 0..chunks.y { |
| 178 |
for cx in 0..chunks.x { |
| 179 |
let origin = UVec2::new(cx, cy) * CHUNK_SIZE; |
| 180 |
let center = (origin.as_vec2() + CHUNK_SIZE as f32 / 2.0) * TILE_SIZE; |
| 181 |
commands.spawn(( |
| 182 |
Name::new(format!("{layer:?} chunk {cx},{cy}")), |
| 183 |
Chunk { origin, layer }, |
| 184 |
TilemapChunk { |
| 185 |
chunk_size: UVec2::splat(CHUNK_SIZE), |
| 186 |
tile_display_size: UVec2::splat(TILE_SIZE as u32), |
| 187 |
tileset: tileset.clone(), |
| 188 |
alpha_mode: AlphaMode2d::Blend, |
| 189 |
}, |
| 190 |
TilemapChunkTileData(layer.tiles(&map, &zones, &light, origin)), |
| 191 |
Transform::from_translation(center.extend(layer.z())), |
| 192 |
)); |
| 193 |
} |
| 194 |
} |
| 195 |
} |
| 196 |
} |
| 197 |
|
| 198 |
fn refresh_chunks( |
| 199 |
map: Res<WorldMap>, |
| 200 |
zones: Res<Zones>, |
| 201 |
light: Res<LightMap>, |
| 202 |
mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>, |
| 203 |
) { |
| 204 |
for (chunk, mut data) in &mut chunks { |
| 205 |
data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin); |
| 206 |
} |
| 207 |
} |
| 208 |
|
| 209 |
pub fn light_refresh_due(pending: bool, since_last: Duration) -> bool { |
| 210 |
pending && since_last >= LIGHT_REFRESH_INTERVAL |
| 211 |
} |
| 212 |
|
| 213 |
fn world_or_light_changed( |
| 214 |
mut generated: MessageReader<WorldGenerated>, |
| 215 |
light: Res<LightMap>, |
| 216 |
real: Res<Time<Real>>, |
| 217 |
mut pending: Local<bool>, |
| 218 |
mut last: Local<Duration>, |
| 219 |
) -> bool { |
| 220 |
*pending |= light.is_changed(); |
| 221 |
let now = real.elapsed(); |
| 222 |
let due = generated.read().count() > 0 || light_refresh_due(*pending, now - *last); |
| 223 |
if due { |
| 224 |
*pending = false; |
| 225 |
*last = now; |
| 226 |
} |
| 227 |
due |
| 228 |
} |
| 229 |
|
| 230 |
fn refresh_zone_chunks( |
| 231 |
map: Res<WorldMap>, |
| 232 |
zones: Res<Zones>, |
| 233 |
light: Res<LightMap>, |
| 234 |
mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>, |
| 235 |
) { |
| 236 |
for (chunk, mut data) in &mut chunks { |
| 237 |
if chunk.layer == Layer::Zones { |
| 238 |
data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin); |
| 239 |
} |
| 240 |
} |
| 241 |
} |
| 242 |
|
| 243 |
fn refresh_changed_chunks( |
| 244 |
map: Res<WorldMap>, |
| 245 |
zones: Res<Zones>, |
| 246 |
light: Res<LightMap>, |
| 247 |
mut changes: MessageReader<TileChanged>, |
| 248 |
mut chunks: Query<(&Chunk, &mut TilemapChunkTileData)>, |
| 249 |
) { |
| 250 |
let origins: Vec<UVec2> = changes |
| 251 |
.read() |
| 252 |
.map(|TileChanged(tile)| chunk_origin(*tile)) |
| 253 |
.collect(); |
| 254 |
for (chunk, mut data) in &mut chunks { |
| 255 |
if origins.contains(&chunk.origin) { |
| 256 |
data.0 = chunk.layer.tiles(&map, &zones, &light, chunk.origin); |
| 257 |
} |
| 258 |
} |
| 259 |
} |
| 260 |
|
| 261 |
pub fn chunk_origin(tile: UVec2) -> UVec2 { |
| 262 |
tile / CHUNK_SIZE * CHUNK_SIZE |
| 263 |
} |
| 264 |
|
| 265 |
fn chunk_positions(origin: UVec2) -> impl Iterator<Item = UVec2> { |
| 266 |
(0..CHUNK_SIZE).flat_map(move |y| (0..CHUNK_SIZE).map(move |x| origin + UVec2::new(x, y))) |
| 267 |
} |
| 268 |
|
| 269 |
pub fn chunk_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 270 |
chunk_positions(origin) |
| 271 |
.map(|pos| { |
| 272 |
map.get(pos.as_ivec2()).map(|terrain| TileData { |
| 273 |
tileset_index: terrain.index(), |
| 274 |
color: tint(jitter(pos) * shade(map, pos), light.color(pos)), |
| 275 |
..default() |
| 276 |
}) |
| 277 |
}) |
| 278 |
.collect() |
| 279 |
} |
| 280 |
|
| 281 |
pub fn snow_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 282 |
let cover = light.snow(); |
| 283 |
chunk_positions(origin) |
| 284 |
.map(|pos| { |
| 285 |
let p = pos.as_ivec2(); |
| 286 |
let land = map.get(p).is_some_and(|t| !t.is_water()); |
| 287 |
(cover > 0.0 && land && !map.is_roofed(p)).then(|| { |
| 288 |
let l = light.color(pos); |
| 289 |
TileData { |
| 290 |
tileset_index: 0, |
| 291 |
color: Color::srgba(l.x, l.y, l.z, cover * jitter(pos)), |
| 292 |
..default() |
| 293 |
} |
| 294 |
}) |
| 295 |
}) |
| 296 |
.collect() |
| 297 |
} |
| 298 |
|
| 299 |
pub fn zone_tiles(zones: &Zones, origin: UVec2) -> Vec<Option<TileData>> { |
| 300 |
chunk_positions(origin) |
| 301 |
.map(|pos| { |
| 302 |
zones |
| 303 |
.is_stockpile(pos) |
| 304 |
.then(|| TileData::from_tileset_index(0)) |
| 305 |
}) |
| 306 |
.collect() |
| 307 |
} |
| 308 |
|
| 309 |
pub fn roof_tiles(map: &WorldMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 310 |
chunk_positions(origin) |
| 311 |
.map(|pos| { |
| 312 |
map.is_roofed(pos.as_ivec2()) |
| 313 |
.then(|| TileData::from_tileset_index(0)) |
| 314 |
}) |
| 315 |
.collect() |
| 316 |
} |
| 317 |
|
| 318 |
pub fn plant_tiles(map: &WorldMap, light: &LightMap, origin: UVec2) -> Vec<Option<TileData>> { |
| 319 |
chunk_positions(origin) |
| 320 |
.map(|pos| { |
| 321 |
map.plant(pos.as_ivec2()).map(|plant| TileData { |
| 322 |
tileset_index: PlantLook::of(plant, map.growth(pos.as_ivec2())) as u16, |
| 323 |
color: tint(jitter(pos), light.color(pos)), |
| 324 |
..default() |
| 325 |
}) |
| 326 |
}) |
| 327 |
.collect() |
| 328 |
} |
| 329 |
|
| 330 |
fn tint(v: f32, light: Vec3) -> Color { |
| 331 |
Color::srgb(v * light.x, v * light.y, v * light.z) |
| 332 |
} |
| 333 |
|
| 334 |
fn jitter(pos: UVec2) -> f32 { |
| 335 |
0.92 + (hash2(pos.x, pos.y) % 9) as f32 * 0.01 |
| 336 |
} |
| 337 |
|
| 338 |
fn shade(map: &WorldMap, pos: UVec2) -> f32 { |
| 339 |
match map.get(pos.as_ivec2()) { |
| 340 |
Some(t) if t.is_water() => 1.0, |
| 341 |
_ => SHADE_FLAT * height_brightness(map, pos), |
| 342 |
} |
| 343 |
} |
| 344 |
|
| 345 |
fn height_brightness(map: &WorldMap, pos: UVec2) -> f32 { |
| 346 |
0.9 + 0.2 * map.height(pos.as_ivec2()).unwrap_or(0.0) / MAX_HEIGHT |
| 347 |
} |
| 348 |
|
| 349 |
pub fn hillshade(map: &WorldMap, pos: UVec2) -> f32 { |
| 350 |
let p = pos.as_ivec2(); |
| 351 |
let center = map.height(p).unwrap_or(0.0); |
| 352 |
let h = |d: IVec2| map.height(p + d).unwrap_or(center); |
| 353 |
let dx = (h(IVec2::X) - h(IVec2::NEG_X)) / 2.0; |
| 354 |
let dy = (h(IVec2::Y) - h(IVec2::NEG_Y)) / 2.0; |
| 355 |
let normal = Vec3::new(-dx * RELIEF, -dy * RELIEF, 1.0).normalize(); |
| 356 |
let light = LIGHT.normalize(); |
| 357 |
(normal.dot(light) / light.z).clamp(0.6, 1.25) |
| 358 |
} |
| 359 |
|
| 360 |
fn terrain_texture(terrain: Terrain) -> Texture { |
| 361 |
let base = terrain.color(); |
| 362 |
let layer = terrain.index() as u32; |
| 363 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 364 |
.map(|i| { |
| 365 |
let speck = terrain |
| 366 |
.speck_color() |
| 367 |
.filter(|_| hash2(i / 2 + i / 32, layer) % 5 == 0); |
| 368 |
let jitter = (hash2(i, layer) % 16) as f32 / 255.0 - 8.0 / 255.0; |
| 369 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 370 |
let mortar = terrain == Terrain::Paved |
| 371 |
&& (y % 5 == 0 || (x + if (y / 5) % 2 == 0 { 0 } else { 4 }) % 8 == 0); |
| 372 |
let seam = terrain == Terrain::WoodFloor && (y % 4 == 0 || (x + (y / 4) * 5) % 16 == 0); |
| 373 |
let rail = terrain == Terrain::Bridge && (x <= 1 || x >= TEXTURE_SIZE - 2); |
| 374 |
let gap = terrain == Terrain::Bridge && y % 4 == 0; |
| 375 |
let c = if mortar { |
| 376 |
Srgba::rgb_u8(110, 106, 100) |
| 377 |
} else if seam { |
| 378 |
Srgba::rgb_u8(100, 70, 42) |
| 379 |
} else if rail { |
| 380 |
Srgba::rgb_u8(80, 56, 34) |
| 381 |
} else if gap { |
| 382 |
Terrain::ShallowWater.color() |
| 383 |
} else { |
| 384 |
speck.unwrap_or(base) |
| 385 |
}; |
| 386 |
Srgba::new(c.red + jitter, c.green + jitter, c.blue + jitter, 1.0).to_u8_array() |
| 387 |
}) |
| 388 |
.collect() |
| 389 |
} |
| 390 |
|
| 391 |
fn zone_texture() -> Texture { |
| 392 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 393 |
.map(|i| { |
| 394 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 395 |
let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1; |
| 396 |
[90, 160, 230, if edge { 150 } else { 55 }] |
| 397 |
}) |
| 398 |
.collect() |
| 399 |
} |
| 400 |
|
| 401 |
fn snow_texture() -> Texture { |
| 402 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 403 |
.map(|i| { |
| 404 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 405 |
let speck = hash2(x, y + 7) % 7 == 0; |
| 406 |
if speck { |
| 407 |
[215, 225, 240, 255] |
| 408 |
} else { |
| 409 |
[245, 248, 255, 255] |
| 410 |
} |
| 411 |
}) |
| 412 |
.collect() |
| 413 |
} |
| 414 |
|
| 415 |
fn roof_texture() -> Texture { |
| 416 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 417 |
.map(|i| { |
| 418 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 419 |
let stripe = (x + y) % 4 == 0; |
| 420 |
[110, 80, 50, if stripe { 90 } else { 45 }] |
| 421 |
}) |
| 422 |
.collect() |
| 423 |
} |
| 424 |
|
| 425 |
#[derive(Clone, Copy, Debug, PartialEq, Eq)] |
| 426 |
pub enum PlantLook { |
| 427 |
Tree, |
| 428 |
Bush, |
| 429 |
RipeBush, |
| 430 |
Sprout, |
| 431 |
YoungCrop, |
| 432 |
RipeCrop, |
| 433 |
Herb, |
| 434 |
RipeHerb, |
| 435 |
Sapling, |
| 436 |
} |
| 437 |
|
| 438 |
impl PlantLook { |
| 439 |
const ALL: [PlantLook; 9] = [ |
| 440 |
PlantLook::Tree, |
| 441 |
PlantLook::Bush, |
| 442 |
PlantLook::RipeBush, |
| 443 |
PlantLook::Sprout, |
| 444 |
PlantLook::YoungCrop, |
| 445 |
PlantLook::RipeCrop, |
| 446 |
PlantLook::Herb, |
| 447 |
PlantLook::RipeHerb, |
| 448 |
PlantLook::Sapling, |
| 449 |
]; |
| 450 |
|
| 451 |
pub fn of(plant: Plant, growth: f32) -> Self { |
| 452 |
match plant { |
| 453 |
Plant::Tree => PlantLook::Tree, |
| 454 |
Plant::BerryBush if plant.is_ripe(growth) => PlantLook::RipeBush, |
| 455 |
Plant::BerryBush => PlantLook::Bush, |
| 456 |
Plant::Crop if plant.is_ripe(growth) => PlantLook::RipeCrop, |
| 457 |
Plant::Crop if growth >= SPROUT_UNTIL => PlantLook::YoungCrop, |
| 458 |
Plant::Crop => PlantLook::Sprout, |
| 459 |
Plant::Herb if plant.is_ripe(growth) => PlantLook::RipeHerb, |
| 460 |
Plant::Herb => PlantLook::Herb, |
| 461 |
Plant::Sapling => PlantLook::Sapling, |
| 462 |
} |
| 463 |
} |
| 464 |
} |
| 465 |
|
| 466 |
pub const SPROUT_UNTIL: f32 = 0.4; |
| 467 |
|
| 468 |
fn farm_texture() -> Texture { |
| 469 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 470 |
.map(|i| { |
| 471 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 472 |
let edge = x == 0 || y == 0 || x == TEXTURE_SIZE - 1 || y == TEXTURE_SIZE - 1; |
| 473 |
let furrow = x % 4 == 0; |
| 474 |
match (edge, furrow) { |
| 475 |
(true, _) => [150, 190, 90, 140], |
| 476 |
(_, true) => [80, 56, 34, 150], |
| 477 |
_ => [110, 78, 48, 110], |
| 478 |
} |
| 479 |
}) |
| 480 |
.collect() |
| 481 |
} |
| 482 |
|
| 483 |
fn plant_texture(look: PlantLook) -> Texture { |
| 484 |
let leaves = Srgba::rgb_u8(52, 110, 50); |
| 485 |
let berry = Srgba::rgb_u8(190, 40, 90); |
| 486 |
let stalk = Srgba::rgb_u8(110, 160, 60); |
| 487 |
let grain = Srgba::rgb_u8(220, 190, 80); |
| 488 |
let herb = Srgba::rgb_u8(90, 150, 110); |
| 489 |
let bloom = Srgba::rgb_u8(200, 170, 240); |
| 490 |
match look { |
| 491 |
PlantLook::Tree => tree_texture(), |
| 492 |
PlantLook::Bush => blob_texture(5.0, leaves, None), |
| 493 |
PlantLook::RipeBush => blob_texture(5.0, leaves, Some(berry)), |
| 494 |
PlantLook::Sprout => rows_texture(3, stalk, None), |
| 495 |
PlantLook::YoungCrop => rows_texture(7, stalk, None), |
| 496 |
PlantLook::RipeCrop => rows_texture(10, stalk, Some(grain)), |
| 497 |
PlantLook::Herb => blob_texture(3.0, herb, None), |
| 498 |
PlantLook::RipeHerb => blob_texture(3.5, herb, Some(bloom)), |
| 499 |
PlantLook::Sapling => sapling_texture(), |
| 500 |
} |
| 501 |
} |
| 502 |
|
| 503 |
fn blob_texture(radius: f32, color: Srgba, dots: Option<Srgba>) -> Texture { |
| 504 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 505 |
.map(|i| { |
| 506 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 507 |
let d = Vec2::new(x as f32, y as f32).distance(Vec2::new(7.5, 8.5)); |
| 508 |
if d > radius { |
| 509 |
return [0, 0, 0, 0]; |
| 510 |
} |
| 511 |
match dots { |
| 512 |
Some(dot) if hash2(x, y) % 5 == 0 => dot.to_u8_array(), |
| 513 |
_ => color.to_u8_array(), |
| 514 |
} |
| 515 |
}) |
| 516 |
.collect() |
| 517 |
} |
| 518 |
|
| 519 |
fn rows_texture(height: u32, stalk: Srgba, head: Option<Srgba>) -> Texture { |
| 520 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 521 |
.map(|i| { |
| 522 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 523 |
let in_row = x % 4 == 1 || x % 4 == 2; |
| 524 |
let top = TEXTURE_SIZE - 2 - height; |
| 525 |
if !in_row || y < top || y >= TEXTURE_SIZE - 2 { |
| 526 |
return [0, 0, 0, 0]; |
| 527 |
} |
| 528 |
match head { |
| 529 |
Some(head) if y < top + 3 => head.to_u8_array(), |
| 530 |
_ => stalk.to_u8_array(), |
| 531 |
} |
| 532 |
}) |
| 533 |
.collect() |
| 534 |
} |
| 535 |
|
| 536 |
fn sapling_texture() -> Texture { |
| 537 |
let leaves = Srgba::rgb_u8(66, 126, 58); |
| 538 |
let trunk = Srgba::rgb_u8(92, 64, 40); |
| 539 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 540 |
.map(|i| { |
| 541 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 542 |
let d = Vec2::new(x as f32, y as f32).distance(Vec2::new(7.5, 8.5)); |
| 543 |
if d <= 3.0 { |
| 544 |
leaves.to_u8_array() |
| 545 |
} else if (7..=8).contains(&x) && (11..=13).contains(&y) { |
| 546 |
trunk.to_u8_array() |
| 547 |
} else { |
| 548 |
[0, 0, 0, 0] |
| 549 |
} |
| 550 |
}) |
| 551 |
.collect() |
| 552 |
} |
| 553 |
|
| 554 |
fn tree_texture() -> Texture { |
| 555 |
let leaves = Srgba::rgb_u8(38, 92, 42); |
| 556 |
let highlight = Srgba::rgb_u8(66, 126, 58); |
| 557 |
let trunk = Srgba::rgb_u8(92, 64, 40); |
| 558 |
let canopy_center = Vec2::new(7.5, 6.5); |
| 559 |
(0..TEXTURE_SIZE * TEXTURE_SIZE) |
| 560 |
.map(|i| { |
| 561 |
let (x, y) = (i % TEXTURE_SIZE, i / TEXTURE_SIZE); |
| 562 |
let p = Vec2::new(x as f32, y as f32); |
| 563 |
let from_center = p - canopy_center; |
| 564 |
if from_center.length() <= 6.0 { |
| 565 |
let lit = from_center.dot(Vec2::new(-1.0, -1.0)) > 3.0; |
| 566 |
if lit { highlight } else { leaves }.to_u8_array() |
| 567 |
} else if (7..=8).contains(&x) && y >= 12 && y <= 14 { |
| 568 |
trunk.to_u8_array() |
| 569 |
} else { |
| 570 |
[0, 0, 0, 0] |
| 571 |
} |
| 572 |
}) |
| 573 |
.collect() |
| 574 |
} |
| 575 |
|
| 576 |
pub fn tileset_layer_count(textures: u32) -> u32 { |
| 577 |
match textures { |
| 578 |
0 | 1 => 2, |
| 579 |
n if n % 6 == 0 => n + 1, |
| 580 |
n => n, |
| 581 |
} |
| 582 |
} |
| 583 |
|
| 584 |
fn stack_tileset(textures: Vec<Texture>) -> Image { |
| 585 |
let layers = tileset_layer_count(textures.len() as u32); |
| 586 |
let empty = vec![[0, 0, 0, 0]; (TEXTURE_SIZE * TEXTURE_SIZE) as usize]; |
| 587 |
let data = (0..layers as usize) |
| 588 |
.flat_map(|i| textures.get(i).unwrap_or(&empty).clone()) |
| 589 |
.flatten() |
| 590 |
.collect(); |
| 591 |
let mut image = Image::new( |
| 592 |
Extent3d { |
| 593 |
width: TEXTURE_SIZE, |
| 594 |
height: TEXTURE_SIZE * layers, |
| 595 |
depth_or_array_layers: 1, |
| 596 |
}, |
| 597 |
TextureDimension::D2, |
| 598 |
data, |
| 599 |
TextureFormat::Rgba8UnormSrgb, |
| 600 |
RenderAssetUsages::RENDER_WORLD, |
| 601 |
); |
| 602 |
image |
| 603 |
.reinterpret_stacked_2d_as_array(layers) |
| 604 |
.expect("tileset is a vertical stack of square layers"); |
| 605 |
image |
| 606 |
} |
| 607 |
|
| 608 |
#[cfg(test)] |
| 609 |
mod tests { |
| 610 |
use super::*; |
| 611 |
|
| 612 |
#[test] |
| 613 |
fn chunk_tiles_beyond_map_edge_are_empty() { |
| 614 |
let map = WorldMap::filled(UVec2::new(CHUNK_SIZE + 2, 3), Terrain::Stone); |
| 615 |
let tiles = chunk_tiles(&map, &LightMap::default(), UVec2::new(CHUNK_SIZE, 0)); |
| 616 |
assert_eq!(tiles.len(), (CHUNK_SIZE * CHUNK_SIZE) as usize); |
| 617 |
assert!(tiles[0].is_some()); |
| 618 |
assert!(tiles[1].is_some()); |
| 619 |
assert!(tiles[2].is_none()); |
| 620 |
assert!(tiles[CHUNK_SIZE as usize * 3].is_none()); |
| 621 |
} |
| 622 |
|
| 623 |
|
| 624 |
|
| 625 |
|
| 626 |
#[test] |
| 627 |
fn regeneration_rewrites_tile_data_of_existing_chunks() { |
| 628 |
let mut app = App::new(); |
| 629 |
app.insert_resource(WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass)) |
| 630 |
.init_resource::<Zones>() |
| 631 |
.init_resource::<LightMap>() |
| 632 |
.add_message::<WorldGenerated>() |
| 633 |
.add_systems(Update, refresh_chunks.run_if(on_message::<WorldGenerated>)); |
| 634 |
let map = app.world().resource::<WorldMap>().clone(); |
| 635 |
let chunk = app |
| 636 |
.world_mut() |
| 637 |
.spawn(( |
| 638 |
Chunk { |
| 639 |
origin: UVec2::ZERO, |
| 640 |
layer: Layer::Terrain, |
| 641 |
}, |
| 642 |
TilemapChunkTileData(chunk_tiles(&map, &LightMap::default(), UVec2::ZERO)), |
| 643 |
)) |
| 644 |
.id(); |
| 645 |
|
| 646 |
*app.world_mut().resource_mut::<WorldMap>() = |
| 647 |
WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Stone); |
| 648 |
app.world_mut().write_message(WorldGenerated); |
| 649 |
app.update(); |
| 650 |
|
| 651 |
let data = app.world().get::<TilemapChunkTileData>(chunk).unwrap(); |
| 652 |
assert!( |
| 653 |
data.0 |
| 654 |
.iter() |
| 655 |
.all(|t| t.unwrap().tileset_index == Terrain::Stone.index()) |
| 656 |
); |
| 657 |
} |
| 658 |
|
| 659 |
#[test] |
| 660 |
fn darkness_tints_tiles_and_fires_warm_them() { |
| 661 |
let map = WorldMap::filled(UVec2::splat(CHUNK_SIZE), Terrain::Grass); |
| 662 |
let fire = ( |
| 663 |
UVec2::new(10, 10), |
| 664 |
crate::structures::StructureKind::Campfire, |
| 665 |
); |
| 666 |
let night = LightMap::compute(&map, 0.0, [fire]); |
| 667 |
let tiles = chunk_tiles(&map, &night, UVec2::ZERO); |
| 668 |
let lum = |pos: UVec2| { |
| 669 |
let c = tiles[(pos.y * CHUNK_SIZE + pos.x) as usize] |
| 670 |
.unwrap() |
| 671 |
.color |
| 672 |
.to_srgba(); |
| 673 |
c.red + c.green + c.blue |
| 674 |
}; |
| 675 |
assert!(lum(UVec2::new(10, 11)) > lum(UVec2::new(40, 40)) * 2.0); |
| 676 |
} |
| 677 |
|
| 678 |
#[test] |
| 679 |
fn roof_layer_covers_roofed_tiles_only() { |
| 680 |
let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); |
| 681 |
map.set_roof(UVec2::new(2, 1), true); |
| 682 |
let tiles = roof_tiles(&map, UVec2::ZERO); |
| 683 |
assert!(tiles[(CHUNK_SIZE + 2) as usize].is_some()); |
| 684 |
assert_eq!(tiles.iter().filter(|t| t.is_some()).count(), 1); |
| 685 |
} |
| 686 |
|
| 687 |
#[test] |
| 688 |
fn chunk_origin_rounds_down_to_chunk_grid() { |
| 689 |
assert_eq!(chunk_origin(UVec2::new(0, 63)), UVec2::new(0, 0)); |
| 690 |
assert_eq!(chunk_origin(UVec2::new(64, 130)), UVec2::new(64, 128)); |
| 691 |
} |
| 692 |
|
| 693 |
#[test] |
| 694 |
fn tileset_has_a_layer_for_every_texture() { |
| 695 |
let textures = Terrain::ALL.iter().map(|&t| terrain_texture(t)).collect(); |
| 696 |
let image = stack_tileset(textures); |
| 697 |
assert!(image.texture_descriptor.size.depth_or_array_layers >= Terrain::ALL.len() as u32); |
| 698 |
} |
| 699 |
|
| 700 |
#[test] |
| 701 |
fn plants_look_different_as_they_grow() { |
| 702 |
assert_eq!(PlantLook::of(Plant::Crop, 0.1), PlantLook::Sprout); |
| 703 |
assert_eq!(PlantLook::of(Plant::Crop, 0.7), PlantLook::YoungCrop); |
| 704 |
assert_eq!(PlantLook::of(Plant::Crop, 1.0), PlantLook::RipeCrop); |
| 705 |
assert_eq!(PlantLook::of(Plant::BerryBush, 0.5), PlantLook::Bush); |
| 706 |
assert_eq!(PlantLook::of(Plant::BerryBush, 1.0), PlantLook::RipeBush); |
| 707 |
} |
| 708 |
|
| 709 |
#[test] |
| 710 |
fn plant_layer_only_has_tiles_where_plants_grow() { |
| 711 |
let mut map = WorldMap::filled(UVec2::splat(4), Terrain::Grass); |
| 712 |
map.set_plant(UVec2::new(1, 0), Some(Plant::Tree)); |
| 713 |
let tiles = plant_tiles(&map, &LightMap::default(), UVec2::ZERO); |
| 714 |
assert!(tiles[0].is_none()); |
| 715 |
assert_eq!(tiles[1].unwrap().tileset_index, PlantLook::Tree as u16); |
| 716 |
} |
| 717 |
|
| 718 |
|
| 719 |
#[test] |
| 720 |
fn slopes_facing_the_light_are_brighter_than_slopes_facing_away() { |
| 721 |
let mut map = WorldMap::filled(UVec2::new(5, 1), Terrain::Grass); |
| 722 |
for (x, h) in [0.0, 5.0, 10.0, 5.0, 0.0].into_iter().enumerate() { |
| 723 |
map.set_height(UVec2::new(x as u32, 0), h); |
| 724 |
} |
| 725 |
let flat = WorldMap::filled(UVec2::new(3, 1), Terrain::Grass); |
| 726 |
let facing_west = hillshade(&map, UVec2::new(1, 0)); |
| 727 |
let facing_east = hillshade(&map, UVec2::new(3, 0)); |
| 728 |
let level = hillshade(&flat, UVec2::new(1, 0)); |
| 729 |
assert!(facing_west > level); |
| 730 |
assert!(facing_east < level); |
| 731 |
} |
| 732 |
|
| 733 |
|
| 734 |
|
| 735 |
|
| 736 |
|
| 737 |
|
| 738 |
#[test] |
| 739 |
fn shadows_darken_slopes_facing_away_from_the_light() { |
| 740 |
assert_eq!(shadow_alpha(1.25), 0); |
| 741 |
assert!(shadow_alpha(0.7) > shadow_alpha(1.0)); |
| 742 |
assert_eq!(shadow_alpha(0.0), (MAX_SHADOW * 255.0) as u8); |
| 743 |
} |
| 744 |
|
| 745 |
|
| 746 |
|
| 747 |
#[test] |
| 748 |
fn light_refreshes_are_rate_limited() { |
| 749 |
assert!(!light_refresh_due(true, Duration::from_millis(20))); |
| 750 |
assert!(light_refresh_due(true, LIGHT_REFRESH_INTERVAL)); |
| 751 |
assert!(!light_refresh_due(false, Duration::from_secs(5))); |
| 752 |
} |
| 753 |
|
| 754 |
#[test] |
| 755 |
fn tileset_layer_count_avoids_webgl_view_dimension_heuristics() { |
| 756 |
for n in 0..50 { |
| 757 |
let layers = tileset_layer_count(n); |
| 758 |
assert!(layers >= n.max(2)); |
| 759 |
assert!(layers % 6 != 0, "{n} terrains -> {layers} layers"); |
| 760 |
} |
| 761 |
} |
| 762 |
} |