//! 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); 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 { 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); #[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); #[derive(Resource)] pub struct Selected(pub Option); #[derive(Resource)] pub struct Names(pub std::collections::HashMap); // lowercase -> entity pub fn spawn_people( mut commands: Commands, mut meshes: ResMut>, mut materials: ResMut>, cfg: Res, spots: Res, font: Res, ) { 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, spots: Res, ) { 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, phase: Res, mut query: Query<(&Bubble, &Children)>, mut texts: Query<(&mut Text2d, &mut Visibility), With>, ) { // 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) }