swarm / village/src/agent.rs

raw file 8653 bytes
//! 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));
}