//! People: components, spawning, straight-line movement, name/speech labels.
use bevy::prelude::*;
use std::collections::VecDeque;
use crate::company::{slug, CompanyCfg};
use crate::scene::{Spots, AGENT_Y};
use crate::UIFont;
use crate::sim::SimClock;
#[derive(Component)]
pub struct Person {
pub idx: usize,
pub name: String,
pub role: String,
pub manager: bool,
pub location: &'static str, // "desk" | "meeting" | "break"
}
#[derive(Component)]
pub struct Memory(pub VecDeque<String>);
impl Memory {
pub fn push(&mut self, event: String) {
if self.0.len() >= 30 {
self.0.pop_front();
}
self.0.push_back(event);
}
pub fn recent(&self, n: usize) -> impl Iterator<Item = &String> {
let skip = self.0.len().saturating_sub(n);
self.0.iter().skip(skip)
}
}
/// Where this person is currently walking to (None = standing).
#[derive(Component)]
pub struct Walking(pub Option<Vec2>);
#[derive(Component, Default)]
pub struct Bubble {
pub text: String,
pub expires: f32, // sim minutes
}
#[derive(Component)]
pub struct NameLabel;
/// Messages the user (the "boss") queued for this person; consumed by
/// gather_due and injected straight into their decision prompt.
#[derive(Component, Default)]
pub struct BossNotes(pub Vec<String>);
#[derive(Resource)]
pub struct Selected(pub Option<Entity>);
#[derive(Resource)]
pub struct Names(pub std::collections::HashMap<String, Entity>); // lowercase -> entity
pub fn spawn_people(
mut commands: Commands,
mut meshes: ResMut<Assets<Mesh>>,
mut materials: ResMut<Assets<StandardMaterial>>,
cfg: Res<CompanyCfg>,
spots: Res<Spots>,
font: Res<UIFont>,
) {
let body = meshes.add(Capsule3d::new(14.0, 16.0)); // total height 44
let mut names = std::collections::HashMap::new();
for (i, p) in cfg.people.iter().enumerate() {
let hue = (i as f32 * 37.0) % 360.0;
let color = if p.is_manager {
Color::srgb(0.8, 0.2, 0.2)
} else {
Color::hsl(hue, 0.55, 0.55)
};
let start = spots.desks.get(i).copied().unwrap_or(spots.entrance);
let entity = commands
.spawn((
Person {
idx: i,
name: p.name.clone(),
role: p.role.clone(),
manager: p.is_manager,
location: "desk",
},
Memory(VecDeque::new()),
BossNotes::default(),
Bubble::default(),
Walking(None),
Mesh3d(body.clone()),
MeshMaterial3d(materials.add(StandardMaterial {
base_color: color,
perceptual_roughness: 0.8,
..default()
})),
Transform::from_translation(Vec3::new(start.x, AGENT_Y, start.y)),
))
.with_children(|parent| {
// name + title above the person (★ for the manager)
let star = if p.is_manager { "★ " } else { "" };
parent.spawn((
Text2d::new(format!("{} {}\n{}", star, p.name, p.role)),
TextFont {
font: font.0.clone().into(),
font_size: 13.0.into(),
..default()
},
TextColor(Color::srgb(0.95, 0.97, 1.0)),
TextLayout::default().with_justify(Justify::Center),
Transform::from_xyz(0.0, 40.0, 0.0),
NameLabel,
));
// speech bubble (hidden by default)
parent.spawn((
Text2d::default(),
TextFont {
font: font.0.clone().into(),
font_size: 13.0.into(),
..default()
},
TextColor(Color::srgba(0.15, 0.2, 0.4, 0.95)),
Transform::from_xyz(0.0, 56.0, 0.0),
Visibility::Hidden,
BubbleText,
));
})
.id();
names.insert(p.name.to_lowercase(), entity);
}
commands.insert_resource(Names(names));
}
#[derive(Component)]
pub struct BubbleText;
/// Headless variant (TUI companion / `--headless` runs): the same components
/// round.rs needs, minus meshes, fonts and children — nothing renders.
pub fn spawn_people_headless(
mut commands: Commands,
cfg: Res<CompanyCfg>,
spots: Res<Spots>,
) {
let mut names = std::collections::HashMap::new();
for (i, p) in cfg.people.iter().enumerate() {
let start = spots.desks.get(i).copied().unwrap_or(spots.entrance);
let entity = commands
.spawn((
Person {
idx: i,
name: p.name.clone(),
role: p.role.clone(),
manager: p.is_manager,
location: "desk",
},
Memory(VecDeque::new()),
BossNotes::default(),
Bubble::default(),
Walking(None),
Transform::from_translation(Vec3::new(start.x, AGENT_Y, start.y)),
))
.id();
names.insert(p.name.to_lowercase(), entity);
}
commands.insert_resource(Names(names));
}
/// Movement lives in main.rs (it also drives `person.location` bookkeeping
/// through action application in round.rs).
pub fn update_bubbles(
clock: Res<SimClock>,
phase: Res<crate::sim::Phase>,
mut query: Query<(&Bubble, &Children)>,
mut texts: Query<(&mut Text2d, &mut Visibility), With<BubbleText>>,
) {
// Bubbles expire on sim time, which only moves during Stepping anyway.
let _ = phase;
for (bubble, children) in &query {
for child in children.iter() {
let Ok((mut text, mut visibility)) = texts.get_mut(child) else {
continue;
};
if bubble.text.is_empty() || clock.0 > bubble.expires {
*visibility = Visibility::Hidden;
continue;
}
*visibility = Visibility::Visible;
text.0 = bubble.text.clone();
}
}
}
pub fn person_dir(name: &str) -> String {
slug(name)
}