swarm / office/src/people.rs

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