Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 199 additions & 1 deletion rust/src/constants/buildings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ use crate::world::BuildingKind;
#[derive(Clone, Copy, Debug)]
pub enum TileSpec {
Single(u32, u32),
/// Multiple sprite variants at (col, row). First entry is the base atlas layer.
/// Extra variants are appended to the atlas after autotile extras.
Pick(&'static [(u32, u32)]),
Quad([(u32, u32); 4]), // [TL, TR, BL, BR]
External(&'static str), // asset path, e.g. "sprites/house.png"
}
Expand Down Expand Up @@ -702,7 +705,7 @@ pub const BUILDING_REGISTRY: &[BuildingDef] = &[
BuildingDef {
kind: BuildingKind::TreeNode,
display: DisplayCategory::Hidden,
tile: TileSpec::Single(13, 9),
tile: TileSpec::Pick(&[(13, 9), (14, 9), (15, 9), (16, 9), (17, 9)]),
hp: 1.0,
cost: 0,
label: "Tree Node",
Expand Down Expand Up @@ -912,3 +915,198 @@ pub fn autotile_col(kind: BuildingKind, variant: u16) -> f32 {
let extra_base = BUILDING_REGISTRY.len() + order * AUTOTILE_EXTRA_PER_KIND;
(extra_base as u16 + variant - 1) as f32
}

// ── Pick tile variant helpers ──────────────────────────────────────────────

/// Total extra atlas layers from Pick tile specs (all variants beyond the first).
pub fn pick_total_extra_layers() -> usize {
BUILDING_REGISTRY
.iter()
.map(|d| match d.tile {
TileSpec::Pick(v) if v.len() > 1 => v.len() - 1,
_ => 0,
})
.sum()
}

/// Atlas layer index for the k-th variant (0-based) of a Pick tile kind.
/// Variant 0 maps to the base tileset index. Variants 1+ are in the pick extras
/// region, which follows the autotile extras region.
/// Returns None if kind is not Pick or k >= variant count.
pub fn pick_variant_atlas_layer(kind: BuildingKind, k: usize) -> Option<usize> {
let def = BUILDING_REGISTRY.iter().find(|d| d.kind == kind)?;
let TileSpec::Pick(variants) = def.tile else {
return None;
};
if k >= variants.len() {
return None;
}
if k == 0 {
return Some(tileset_index(kind) as usize);
}
// Pick extras start after base layers + autotile extras.
let pick_extras_base = BUILDING_REGISTRY.len() + autotile_total_extra_layers();
// Count how many pick extra slots come before this kind.
let mut offset = 0usize;
for d in BUILDING_REGISTRY {
if d.kind == kind {
break;
}
if let TileSpec::Pick(v) = d.tile {
if v.len() > 1 {
offset += v.len() - 1;
}
}
}
Some(pick_extras_base + offset + k - 1)
}

/// Deterministic per-position variant index for a Pick tile kind (0-based).
/// Uses integer grid coordinates derived from world position.
pub fn pick_variant_for_pos(kind: BuildingKind, wx: f32, wy: f32) -> usize {
let def = match BUILDING_REGISTRY.iter().find(|d| d.kind == kind) {
Some(d) => d,
None => return 0,
};
let count = match def.tile {
TileSpec::Pick(v) => v.len(),
_ => return 0,
};
if count <= 1 {
return 0;
}
// Grid-coordinate hash: stable across f32 representation changes.
let cell = crate::world::CELL as i32;
let gx = (wx / cell as f32).round() as i32;
let gy = (wy / cell as f32).round() as i32;
let h = (gx.wrapping_mul(374761393i32) ^ gy.wrapping_mul(668265263i32)) as u32;
h as usize % count
}

#[cfg(test)]
mod tests {
use super::*;
use crate::world::BuildingKind;

#[test]
fn tree_node_uses_pick_tile() {
let def = building_def(BuildingKind::TreeNode);
assert!(
matches!(def.tile, TileSpec::Pick(_)),
"TreeNode must use TileSpec::Pick for multi-variant sprites"
);
}

#[test]
fn tree_node_has_at_least_four_variants() {
let def = building_def(BuildingKind::TreeNode);
let TileSpec::Pick(variants) = def.tile else {
panic!("TreeNode tile is not Pick");
};
assert!(
variants.len() >= 4,
"TreeNode must have at least 4 sprite variants, got {}",
variants.len()
);
}

#[test]
fn pick_variant_for_pos_is_deterministic() {
// Same position always yields the same variant.
let v1 = pick_variant_for_pos(BuildingKind::TreeNode, 256.0, 512.0);
let v2 = pick_variant_for_pos(BuildingKind::TreeNode, 256.0, 512.0);
assert_eq!(v1, v2, "variant selection must be deterministic");
}

#[test]
fn pick_variant_for_pos_in_bounds() {
let def = building_def(BuildingKind::TreeNode);
let TileSpec::Pick(variants) = def.tile else {
panic!("TreeNode tile is not Pick");
};
let count = variants.len();
// Test a range of positions -- all must produce a valid variant index.
for gx in -5i32..=5 {
for gy in -5i32..=5 {
let wx = gx as f32 * crate::world::CELL as f32;
let wy = gy as f32 * crate::world::CELL as f32;
let v = pick_variant_for_pos(BuildingKind::TreeNode, wx, wy);
assert!(
v < count,
"variant {} out of bounds for pos ({},{})",
v,
wx,
wy
);
}
}
}

#[test]
fn pick_variant_for_pos_produces_multiple_distinct_variants() {
// Across a grid of positions, we should see more than one distinct variant.
let mut seen = std::collections::HashSet::new();
for gx in 0..10i32 {
for gy in 0..10i32 {
let wx = gx as f32 * crate::world::CELL as f32;
let wy = gy as f32 * crate::world::CELL as f32;
seen.insert(pick_variant_for_pos(BuildingKind::TreeNode, wx, wy));
}
}
assert!(
seen.len() >= 4,
"expected at least 4 distinct variants across 100 positions, got {}",
seen.len()
);
}

#[test]
fn pick_variant_atlas_layer_variant0_is_base() {
let base = tileset_index(BuildingKind::TreeNode) as usize;
let layer0 = pick_variant_atlas_layer(BuildingKind::TreeNode, 0);
assert_eq!(
layer0,
Some(base),
"variant 0 must map to base tileset_index"
);
}

#[test]
fn pick_variant_atlas_layer_extras_are_contiguous_after_autotile() {
let def = building_def(BuildingKind::TreeNode);
let TileSpec::Pick(variants) = def.tile else {
panic!("TreeNode tile is not Pick");
};
let count = variants.len();
let extras_base = BUILDING_REGISTRY.len() + autotile_total_extra_layers();
// Variant 1 is the first pick extra.
let layer1 = pick_variant_atlas_layer(BuildingKind::TreeNode, 1);
assert_eq!(
layer1,
Some(extras_base),
"variant 1 must be first pick extra layer"
);
// All variants must have distinct atlas layers.
let layers: Vec<_> = (0..count)
.filter_map(|k| pick_variant_atlas_layer(BuildingKind::TreeNode, k))
.collect();
let unique: std::collections::HashSet<_> = layers.iter().collect();
assert_eq!(
layers.len(),
unique.len(),
"all variant atlas layers must be distinct"
);
}

#[test]
fn pick_variant_atlas_layer_oob_returns_none() {
let def = building_def(BuildingKind::TreeNode);
let TileSpec::Pick(variants) = def.tile else {
panic!("TreeNode tile is not Pick");
};
assert!(
pick_variant_atlas_layer(BuildingKind::TreeNode, variants.len()).is_none(),
"out-of-bounds variant index must return None"
);
}
}
4 changes: 3 additions & 1 deletion rust/src/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1052,7 +1052,9 @@ fn spawn_world_tilemap(
assert_eq!(
img.height(),
crate::world::ATLAS_CELL
* (btiles.len() + crate::constants::autotile_total_extra_layers()) as u32,
* (btiles.len()
+ crate::constants::autotile_total_extra_layers()
+ crate::constants::pick_total_extra_layers()) as u32,
"building atlas height mismatch"
);
}
Expand Down
2 changes: 2 additions & 0 deletions rust/src/tests/terrain_visual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ pub fn tick(
let tile_idx = grid.cells[idx].terrain.tileset_index(idx);
let label = match world::TERRAIN_TILES[tile_idx as usize] {
world::TileSpec::Single(c, r) => format!("({},{})", c, r),
world::TileSpec::Pick(v) => format!("pick@({},{})", v[0].0, v[0].1),
world::TileSpec::Quad(q) => format!("2x2@({},{})", q[0].0, q[0].1),
world::TileSpec::External(p) => format!("ext:{}", p),
};
Expand All @@ -291,6 +292,7 @@ pub fn tick(
let btiles = world::building_tiles();
let label = match btiles[tile_idx as usize] {
crate::constants::TileSpec::Single(c, r) => format!("({},{})", c, r),
crate::constants::TileSpec::Pick(v) => format!("pick@({},{})", v[0].0, v[0].1),
crate::constants::TileSpec::Quad(q) => format!("2x2@({},{})", q[0].0, q[0].1),
crate::constants::TileSpec::External(p) => format!("ext:{}", p),
};
Expand Down
10 changes: 10 additions & 0 deletions rust/src/ui/build_menu.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,16 @@ fn init_sprite_cache(
cache._handles.push(h.clone());
h
}
TileSpec::Pick(variants) => {
// Show the first variant in the build menu (non-buildable in practice,
// but handle the match arm for completeness).
let (col, row) = variants.first().copied().unwrap_or((0, 0));
let img =
extract_quad_tile(&atlas, [(col, row), (col, row), (col, row), (col, row)]);
let h = images.add(img);
cache._handles.push(h.clone());
h
}
};

let tex_id = contexts.add_image(EguiTextureHandle::Weak(handle.id()));
Expand Down
53 changes: 53 additions & 0 deletions rust/src/world/autotile.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,27 @@ fn build_tile_strip(
for (layer, spec) in tiles.iter().enumerate() {
let l = layer as u32;
match *spec {
crate::constants::TileSpec::Pick(variants) => {
// Bake the first variant as the base layer; extras are appended later.
let (col, row) = variants.first().copied().unwrap_or((0, 0));
let src_x = col * cell_size;
let src_y = row * cell_size;
for ty in 0..sprite {
for tx in 0..sprite {
let si = ((src_y + ty) * atlas_width + (src_x + tx)) as usize * 4;
for oy in 0..scale {
for ox in 0..scale {
let di = (l * out_size * out_size
+ (ty * scale + oy) * out_size
+ (tx * scale + ox))
as usize
* 4;
data[di..di + 4].copy_from_slice(&atlas_data[si..si + 4]);
}
}
}
}
}
crate::constants::TileSpec::Single(col, row) => {
// Nearest-neighbor 4x upscale: each 16px src pixel -> 4x4 dst pixels
let src_x = col * cell_size;
Expand Down Expand Up @@ -441,6 +462,38 @@ pub fn build_building_atlas(
extra_count += crate::constants::AUTOTILE_EXTRA_PER_KIND as u32;
}

// Append Pick extra variant layers (variants 1..N-1 for each Pick kind).
let atlas_data = atlas.data.as_ref().expect("atlas image has no data");
let atlas_width = atlas.width();
let cell_size = CELL as u32;
let sprite = SPRITE_SIZE as u32;
let scale = out_size / sprite;
for def in crate::constants::BUILDING_REGISTRY {
let crate::constants::TileSpec::Pick(variants) = def.tile else {
continue;
};
// Skip the first variant (already baked as base layer).
for &(col, row) in variants.iter().skip(1) {
let src_x = col * cell_size;
let src_y = row * cell_size;
let mut layer_data = vec![0u8; layer_bytes];
for ty in 0..sprite {
for tx in 0..sprite {
let si = ((src_y + ty) * atlas_width + (src_x + tx)) as usize * 4;
for oy in 0..scale {
for ox in 0..scale {
let di =
((ty * scale + oy) * out_size + (tx * scale + ox)) as usize * 4;
layer_data[di..di + 4].copy_from_slice(&atlas_data[si..si + 4]);
}
}
}
}
data.extend_from_slice(&layer_data);
extra_count += 1;
}
}

let total_layers = base_layers + extra_count;
let mut img = Image::new(
Extent3d {
Expand Down
13 changes: 11 additions & 2 deletions rust/src/world/buildings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ pub fn place_building(
dirty_writers: Option<&mut DirtyWriters>,
) -> Result<usize, &'static str> {
use crate::components::*;
use crate::constants::{building_def, tileset_index};
use crate::constants::{
building_def, pick_variant_atlas_layer, pick_variant_for_pos, tileset_index,
};

let def = building_def(kind);

Expand Down Expand Up @@ -415,13 +417,20 @@ pub fn place_building(
entity_map.set_entity(slot, entity);

// GPU state
// For Pick tile kinds, select a deterministic variant from the world position.
let tile_layer = if matches!(def.tile, crate::constants::TileSpec::Pick(_)) {
let variant = pick_variant_for_pos(kind, snapped.x, snapped.y);
pick_variant_atlas_layer(kind, variant).unwrap_or(tileset_index(kind) as usize) as u16
} else {
tileset_index(kind)
};
push_building_gpu_updates(
slot,
kind,
snapped,
faction,
hp,
tileset_index(kind),
tile_layer,
def.is_tower,
gpu_updates,
);
Expand Down
Loading