//! Village map: grid, obstacles, named places, pathfinding, building meshes.
use bevy::prelude::*;
use std::collections::{HashMap, VecDeque};
pub const GRID: i32 = 64;
#[derive(Resource)]
pub struct WorldMap {
/// true = blocked (buildings, props)
pub blocked: Vec<bool>,
/// lowercase place name -> cell
pub pois: HashMap<String, IVec2>,
}
impl WorldMap {
pub fn idx(cell: IVec2) -> usize {
(cell.y * GRID + cell.x) as usize
}
pub fn inside(cell: IVec2) -> bool {
cell.x >= 0 && cell.y >= 0 && cell.x < GRID && cell.y < GRID
}
pub fn walkable(&self, cell: IVec2) -> bool {
Self::inside(cell) && !self.blocked[Self::idx(cell)]
}
pub fn place(&self, name: &str) -> Option<IVec2> {
self.pois.get(&name.to_lowercase()).copied()
}
/// 4-directional BFS. If `to` is blocked, targets its nearest walkable neighbor.
pub fn path(&self, from: IVec2, to: IVec2) -> Option<Vec<IVec2>> {
let goal = if self.walkable(to) {
to
} else {
self.nearest_walkable(to)?
};
if from == goal {
return Some(vec![]);
}
let mut queue = VecDeque::from([from]);
let mut parent: HashMap<IVec2, IVec2> = HashMap::new();
parent.insert(from, from);
let dirs = [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y];
while let Some(cur) = queue.pop_front() {
for d in dirs {
let next = cur + d;
if !self.walkable(next) || parent.contains_key(&next) {
continue;
}
parent.insert(next, cur);
if next == goal {
let mut path = vec![next];
let mut c = next;
while c != from {
c = parent[&c];
path.push(c);
}
path.pop(); // drop start cell
path.reverse();
return Some(path);
}
queue.push_back(next);
}
}
None
}
fn nearest_walkable(&self, cell: IVec2) -> Option<IVec2> {
let mut queue = VecDeque::from([cell]);
let mut seen = HashMap::from([(cell, ())]);
while let Some(c) = queue.pop_front() {
if self.walkable(c) {
return Some(c);
}
for d in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] {
let n = c + d;
if Self::inside(n) && !seen.contains_key(&n) {
seen.insert(n, ());
queue.push_back(n);
}
}
}
None
}
pub fn random_walkable(&self) -> IVec2 {
// Deterministic-ish wander using a cheap LCG seeded by position hash is
// overkill; callers pass their own randomness. Fallback: scan.
for i in 0..self.blocked.len() {
if !self.blocked[i] && i % 7 == 3 {
return IVec2::new((i % GRID as usize) as i32, (i / GRID as usize) as i32);
}
}
IVec2::new(GRID / 2, GRID / 2)
}
}
fn cell_to_world(cell: IVec2) -> Vec3 {
Vec3::new(cell.x as f32 + 0.5, 0.0, cell.y as f32 + 0.5)
}
/// Marker for the main isometric camera (pan/zoom/pick target).
#[derive(Component)]
pub struct VillageCamera;
pub fn spawn_village(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
) {
let mut blocked = vec![false; (GRID * GRID) as usize];
let mut pois: HashMap<String, IVec2> = HashMap::new();
let ground = meshes.add(Cuboid::from_size(Vec3::new(GRID as f32, 0.2, GRID as f32)));
commands.spawn((
Mesh3d(ground),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.36, 0.47, 0.28),
perceptual_roughness: 1.0,
..default()
})),
Transform::from_xyz(GRID as f32 / 2.0, -0.1, GRID as f32 / 2.0),
));
// Plaza (walkable, center) + well in its middle (blocked cell, edge used)
let center = IVec2::new(GRID / 2, GRID / 2);
let plaza_cells = [
center,
center + IVec2::X,
center + IVec2::NEG_X,
center + IVec2::Y,
center + IVec2::NEG_Y,
center + IVec2::new(1, 1),
center + IVec2::new(-1, -1),
center + IVec2::new(1, -1),
center + IVec2::new(-1, 1),
];
for c in plaza_cells {
set_floor(&mut blocked, c, Color::srgb(0.55, 0.5, 0.42), &mut commands, &mut meshes, &mut materials, 0.03);
}
pois.insert("plaza".into(), center);
let well = center + IVec2::new(0, -2);
building(&mut commands, &mut meshes, &mut materials, well, 1.0, 1.4, Color::srgb(0.45, 0.45, 0.5));
set_blocked(&mut blocked, well);
pois.insert("well".into(), well);
// Named buildings around the plaza
let named: &[(&str, IVec2, Vec3, Color)] = &[
("market", center + IVec2::new(6, 0), Vec3::new(4.0, 2.0, 3.0), Color::srgb(0.72, 0.55, 0.3)),
("tavern", center + IVec2::new(-6, 1), Vec3::new(3.0, 2.4, 3.0), Color::srgb(0.6, 0.35, 0.25)),
("workshop", center + IVec2::new(1, 6), Vec3::new(3.0, 1.8, 2.0), Color::srgb(0.5, 0.42, 0.32)),
("weavery", center + IVec2::new(-2, -7), Vec3::new(3.0, 1.8, 2.0), Color::srgb(0.55, 0.45, 0.55)),
("scriptorium", center + IVec2::new(7, -6), Vec3::new(2.5, 2.6, 2.5), Color::srgb(0.4, 0.4, 0.6)),
("granary", center + IVec2::new(-7, -5), Vec3::new(3.5, 2.2, 2.5), Color::srgb(0.65, 0.6, 0.4)),
];
for (name, cell, size, color) in named {
building(&mut commands, &mut meshes, &mut materials, *cell, size.x, size.y, *color);
stamp_blocked(&mut blocked, *cell, *size);
pois.insert((*name).into(), *cell + IVec2::new(0, size.z as i32 / 2)); // door side
}
// Houses: two rings of cottages (cells stay walkable around them)
let mut homes: Vec<IVec2> = Vec::new();
let mut house_idx = 0usize;
for radius in [16i32, 26] {
for (dx, dy) in [(1, 0), (0, 1), (-1, 0), (0, -1), (1, 1), (-1, -1), (1, -1), (-1, 1)] {
let base = center + IVec2::new(dx * radius, dy * radius);
for step in -2..=2 {
let c = base + IVec2::new(dy * step * 4, dx * step * 4);
if !WorldMap::inside(c) || blocked[WorldMap::idx(c)] {
continue;
}
building(&mut commands, &mut meshes, &mut materials, c, 2.0, 1.6, Color::srgb(0.7, 0.62, 0.5));
set_blocked(&mut blocked, c);
homes.push(c);
house_idx += 1;
if house_idx >= 34 {
break;
}
}
if house_idx >= 34 {
break;
}
}
if house_idx >= 34 {
break;
}
}
// Homes are per-person, addressed by index in the prompt ("your home: (x,y)")
for (i, c) in homes.iter().enumerate() {
pois.insert(format!("home_{i}"), *c);
}
// Fields: large walkable work areas at the map edge
let field_centers = [
IVec2::new(6, GRID / 2),
IVec2::new(6, GRID / 2 + 8),
IVec2::new(GRID - 7, GRID / 2),
IVec2::new(GRID - 7, GRID / 2 + 8),
IVec2::new(GRID / 2, GRID - 7),
IVec2::new(GRID / 2 + 8, GRID - 7),
];
for (i, fc) in field_centers.iter().enumerate() {
for y in 0..3 {
for x in 0..3 {
let c = fc + IVec2::new(x - 1, y - 1);
if WorldMap::inside(c) {
set_floor(
&mut blocked,
c,
Color::srgb(0.5, 0.42 + (i as f32) * 0.01, 0.2),
&mut commands,
&mut meshes,
&mut materials,
0.04,
);
}
}
}
pois.insert(format!("field_{i}"), *fc);
pois.insert("field".into(), *fc); // generic alias
}
// Roads: cross through the plaza (visual only)
for axis in 0..2 {
for i in 0..GRID {
let c = if axis == 0 {
IVec2::new(i, GRID / 2)
} else {
IVec2::new(GRID / 2, i)
};
if blocked[WorldMap::idx(c)] {
continue;
}
let tile = meshes.add(Cuboid::from_size(Vec3::new(0.9, 0.05, 0.9)));
commands.spawn((
Mesh3d(tile),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.48, 0.44, 0.36),
perceptual_roughness: 1.0,
..default()
})),
Transform::from_translation(cell_to_world(c) + Vec3::new(0.0, 0.03, 0.0)),
));
}
}
// Lights + iso camera
commands.spawn((
DirectionalLight {
illuminance: 12_000.0,
shadow_maps_enabled: false,
..default()
},
Transform::from_xyz(40.0, 80.0, 20.0).looking_at(Vec3::new(32.0, 0.0, 32.0), Vec3::Y),
));
commands.spawn((
Camera3d::default(),
VillageCamera,
Projection::Orthographic(OrthographicProjection {
scale: 0.11,
..OrthographicProjection::default_3d()
}),
Transform::from_xyz(96.0, 96.0, 96.0).looking_at(Vec3::new(32.0, 0.0, 32.0), Vec3::Y),
));
commands.insert_resource(WorldMap { blocked, pois });
}
fn set_blocked(blocked: &mut [bool], cell: IVec2) {
if WorldMap::inside(cell) {
blocked[WorldMap::idx(cell)] = true;
}
}
fn stamp_blocked(blocked: &mut [bool], cell: IVec2, size: Vec3) {
let w = size.x as i32;
let h = size.z as i32;
for dy in 0..h.max(1) {
for dx in 0..w.max(1) {
set_blocked(blocked, cell + IVec2::new(dx - w / 2, dy - h / 2));
}
}
}
fn building(
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
cell: IVec2,
w: f32,
h: f32,
color: Color,
) {
let mesh = meshes.add(Cuboid::from_size(Vec3::new(w, h, 2.0)));
let pos = cell_to_world(cell);
commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: color,
perceptual_roughness: 0.9,
..default()
})),
Transform::from_translation(pos + Vec3::new(0.0, h / 2.0, 0.0)),
));
// pitched-roof suggestion: a smaller cube on top
let roof = meshes.add(Cuboid::from_size(Vec3::new(w * 0.8, 0.6, 1.6)));
commands.spawn((
Mesh3d(roof),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: Color::srgb(0.4, 0.26, 0.22),
perceptual_roughness: 1.0,
..default()
})),
Transform::from_translation(pos + Vec3::new(0.0, h + 0.3, 0.0)),
));
}
fn set_floor(
_blocked: &mut [bool],
cell: IVec2,
color: Color,
commands: &mut Commands,
meshes: &mut Assets<Mesh>,
materials: &mut Assets<StandardMaterial>,
height: f32,
) {
if !WorldMap::inside(cell) {
return;
}
let mesh = meshes.add(Cuboid::from_size(Vec3::new(0.96, height * 2.0, 0.96)));
commands.spawn((
Mesh3d(mesh),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: color,
perceptual_roughness: 1.0,
..default()
})),
Transform::from_translation(cell_to_world(cell) + Vec3::new(0.0, height, 0.0)),
));
}