//! Villager components, spawning, needs, movement, speech bubbles.
use bevy::prelude::*;
use std::collections::VecDeque;
use crate::persona::Persona;
use crate::world::{WorldMap, GRID};
#[derive(Component)]
pub struct Villager {
pub persona: Persona,
pub home: IVec2,
}
#[derive(Component, Clone, Copy)]
pub struct Needs {
pub hunger: f32, // 0..100, higher = hungrier
pub energy: f32, // 0..100, lower = more tired
pub social: f32, // 0..100, lower = lonelier
}
impl Default for Needs {
fn default() -> Self {
Self { hunger: 20.0, energy: 80.0, social: 50.0 }
}
}
#[derive(Component)]
pub struct Memory(pub VecDeque<String>);
impl Memory {
pub fn push(&mut self, event: String) {
if self.0.len() >= 24 {
self.0.pop_front();
}
self.0.push_back(event);
}
pub fn recent(&self, n: usize) -> impl Iterator<Item = &String> {
self.0.iter().rev().take(n).collect::<Vec<_>>().into_iter().rev()
}
}
#[derive(Component)]
pub struct Brain {
pub in_flight: bool,
pub next_wake: f32,
pub expect_reply: bool,
}
#[derive(Component)]
pub struct Pathing {
pub cells: Vec<IVec2>, // remaining cells; empty = not moving
pub step_progress: f32,
pub destination: String, // place name for arrival memory
}
impl Pathing {
pub fn idle() -> Self {
Self { cells: Vec::new(), step_progress: 0.0, destination: String::new() }
}
pub fn is_moving(&self) -> bool {
!self.cells.is_empty()
}
}
#[derive(Component)]
pub struct Speech {
pub text: String,
pub expires: f32,
}
impl Default for Speech {
fn default() -> Self {
Self { text: String::new(), expires: 0.0 }
}
}
#[derive(Component)]
pub struct BubbleText; // marker for the Text2d child
// ---------------------------------------------------------------------------
// Body layer: runs at frame rate, no LLM involved
// ---------------------------------------------------------------------------
/// Walk the current path (grid BFS waypoints), 2 cells/sec.
pub fn move_bodies(
time: Res<Time>,
mut query: Query<(&mut Transform, &mut Pathing)>,
) {
let dt = time.delta_secs();
for (mut transform, mut pathing) in query.iter_mut() {
let Some(next) = pathing.cells.first().copied() else {
continue;
};
let target = world_of(next);
let flat = Vec3::new(target.x - transform.translation.x, 0.0, target.z - transform.translation.z);
let dist = flat.length();
let step = 2.0 * dt;
if dist <= step {
transform.translation.x = target.x;
transform.translation.z = target.z;
pathing.cells.remove(0);
pathing.step_progress = 0.0;
} else {
transform.translation += flat.normalize() * step;
}
// Face direction of travel
if flat.length_squared() > 1e-4 {
let yaw = flat.x.atan2(flat.z);
transform.rotation = Quat::from_rotation_y(yaw);
}
}
}
/// Hunger rises, energy/social drift; body-level overrides (exhaustion).
pub fn tick_needs(
time: Res<Time>,
mut query: Query<(&mut Needs, &mut Pathing, &Villager)>,
) {
let dt = time.delta_secs();
for (mut needs, mut pathing, _v) in query.iter_mut() {
needs.hunger = (needs.hunger + 0.08 * dt).min(100.0);
let moving = pathing.is_moving();
let burn = if moving { 0.35 } else { 0.12 };
needs.energy = (needs.energy - burn * dt).max(0.0);
needs.social = (needs.social - 0.10 * dt).max(0.0);
// Body override: drop and sleep when spent (no LLM consulted)
if needs.energy <= 1.0 && moving {
pathing.cells.clear();
}
if needs.energy <= 1.0 {
needs.energy = (needs.energy + 2.5 * dt).min(100.0);
}
}
}
/// Fade/expire speech bubbles; update the Text2d child.
pub fn update_bubbles(
time: Res<Time>,
mut query: Query<(&Speech, &Children, &Villager)>,
mut texts: Query<(&mut Text2d, &mut TextColor, &mut Visibility), Without<Villager>>,
) {
let now = time.elapsed_secs();
for (speech, children, villager) in &query {
for child in children.iter() {
let Ok((mut text, mut color, mut visibility)) = texts.get_mut(child) else {
continue;
};
if speech.text.is_empty() || now > speech.expires {
*visibility = Visibility::Hidden;
continue;
}
*visibility = Visibility::Visible;
text.0 = format!("{}: {}", villager.persona.name, speech.text);
let left = speech.expires - now;
color.0 = if left < 2.0 {
// flash before disappearing
if (left * 4.0) as u32 % 2 == 0 {
Color::srgba(1.0, 1.0, 1.0, 0.95)
} else {
Color::srgba(1.0, 1.0, 1.0, 0.45)
}
} else {
Color::srgba(1.0, 1.0, 1.0, 0.95)
};
}
}
}
#[derive(Resource)]
pub struct Names(pub std::collections::HashMap<String, Entity>); // lowercase -> entity
pub fn cell_of(pos: Vec3) -> IVec2 {
IVec2::new(pos.x.floor() as i32, pos.z.floor() as i32)
}
pub fn world_of(cell: IVec2) -> Vec3 {
Vec3::new(cell.x as f32 + 0.5, 0.0, cell.y as f32 + 0.5)
}
pub fn spawn_villagers(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
map: Res<WorldMap>,
personas: Res<crate::sim_config::VillageConfig>,
) {
let capsule = meshes.add(Capsule3d::new(0.16, 0.42));
let mut job_mats: std::collections::HashMap<String, Handle<StandardMaterial>> =
std::collections::HashMap::new();
let mut names = std::collections::HashMap::new();
for (i, persona) in personas.people.iter().enumerate() {
// One hue per job (stable across agents of the same job)
let hue = {
let mut h: u32 = 2166136261;
for b in persona.job.bytes() {
h ^= b as u32;
h = h.wrapping_mul(16777619);
}
(h % 360) as f32
};
let mat = job_mats
.entry(persona.job.clone())
.or_insert_with(|| {
materials.add(StandardMaterial {
base_color: Color::hsl(hue, 0.5, 0.55),
perceptual_roughness: 0.8,
..default()
})
})
.clone();
let home = map
.place(&format!("home_{}", persona.home_index))
.unwrap_or_else(|| IVec2::new(GRID / 2, GRID / 2));
// Homes are blocked cells; stand adjacent
let start = map
.path(home, home) // no-op; find nearest walkable around home
.map(|_| home)
.filter(|c| map.walkable(*c))
.unwrap_or_else(|| {
for r in 1..4 {
for d in [IVec2::X, IVec2::NEG_X, IVec2::Y, IVec2::NEG_Y] {
let c = home + d * r;
if map.walkable(c) {
return c;
}
}
}
IVec2::new(GRID / 2, GRID / 2)
});
let name_lower = persona.name.to_lowercase();
let pos = world_of(start) + Vec3::new((i % 7) as f32 * 0.06, 0.45, (i % 5) as f32 * 0.06);
let entity = commands
.spawn((
Villager { persona: persona.clone(), home },
Needs::default(),
Memory(VecDeque::new()),
Brain {
in_flight: false,
// Stagger first wakes so they don't all ask at once
next_wake: 1.0 + (i as f32) * 0.7 + (i % 13) as f32 * 1.9,
expect_reply: false,
},
Pathing::idle(),
Speech::default(),
Mesh3d(capsule.clone()),
MeshMaterial3d(mat),
Transform::from_translation(pos),
))
.with_children(|parent| {
parent.spawn((
Text2d::default(),
TextFont { font_size: 13.0.into(), ..default() },
TextColor(Color::srgba(1.0, 1.0, 1.0, 0.95)),
Transform::from_xyz(0.0, 0.72, 0.0),
BubbleText,
));
})
.id();
names.insert(name_lower, entity);
}
commands.insert_resource(Names(names));
}