diff --git a/common/src/rtsim.rs b/common/src/rtsim.rs index 638667435c..d3cecf3624 100644 --- a/common/src/rtsim.rs +++ b/common/src/rtsim.rs @@ -219,12 +219,14 @@ pub enum NpcActivity { Dance, } +/// Represents event-like actions that rtsim NPCs can perform to interact with +/// the world #[derive(Clone, Debug)] pub enum NpcAction { - Greet(Actor), + /// Speak the given message, with an optional target for that speech. // TODO: Use some sort of structured, language-independent value that frontends can translate // instead - Say(Cow<'static, str>), + Say(Option, Cow<'static, str>), } // Note: the `serde(name = "...")` is to minimise the length of field diff --git a/rtsim/src/data/faction.rs b/rtsim/src/data/faction.rs index 4a4504825f..9dcf8a5a00 100644 --- a/rtsim/src/data/faction.rs +++ b/rtsim/src/data/faction.rs @@ -10,7 +10,7 @@ pub struct Faction { pub good_or_evil: bool, // TODO: Very stupid, get rid of this } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] pub struct Factions { pub factions: HopSlotMap, } diff --git a/rtsim/src/data/mod.rs b/rtsim/src/data/mod.rs index 45a212cd26..e470dc9d7c 100644 --- a/rtsim/src/data/mod.rs +++ b/rtsim/src/data/mod.rs @@ -23,12 +23,20 @@ use std::{ #[derive(Clone, Serialize, Deserialize)] pub struct Data { pub nature: Nature, + #[serde(default)] pub npcs: Npcs, + #[serde(default)] pub sites: Sites, + #[serde(default)] pub factions: Factions, + #[serde(default)] + pub tick: u64, + #[serde(default)] pub time_of_day: TimeOfDay, + // If true, rtsim data will be ignored (and, hence, overwritten on next save) on load. + #[serde(default)] pub should_purge: bool, } diff --git a/rtsim/src/data/npc.rs b/rtsim/src/data/npc.rs index 3a21af4da2..af79c2ecce 100644 --- a/rtsim/src/data/npc.rs +++ b/rtsim/src/data/npc.rs @@ -4,7 +4,8 @@ use common::{ comp, grid::Grid, rtsim::{ - Actor, ChunkResource, FactionId, NpcAction, NpcActivity, Personality, SiteId, VehicleId, + Actor, ChunkResource, FactionId, NpcAction, NpcActivity, NpcMsg, Personality, SiteId, + VehicleId, }, store::Id, terrain::TerrainChunkSize, @@ -69,10 +70,8 @@ impl Controller { pub fn do_dance(&mut self) { self.activity = Some(NpcActivity::Dance); } - pub fn greet(&mut self, actor: Actor) { self.actions.push(NpcAction::Greet(actor)); } - - pub fn say(&mut self, msg: impl Into>) { - self.actions.push(NpcAction::Say(msg.into())); + pub fn say(&mut self, target: impl Into>, msg: impl Into>) { + self.actions.push(NpcAction::Say(target.into(), msg.into())); } } @@ -206,6 +205,7 @@ pub enum VehicleKind { Boat, } +// TODO: Merge into `Npc`? #[derive(Clone, Serialize, Deserialize)] pub struct Vehicle { pub wpos: Vec3, @@ -273,6 +273,17 @@ pub struct Npcs { pub character_map: HashMap, Vec<(common::character::CharacterId, Vec3)>>, } +impl Default for Npcs { + fn default() -> Self { + Self { + npcs: Default::default(), + vehicles: Default::default(), + npc_grid: construct_npc_grid(), + character_map: Default::default(), + } + } +} + fn construct_npc_grid() -> Grid { Grid::new(Vec2::zero(), Default::default()) } impl Npcs { diff --git a/rtsim/src/data/site.rs b/rtsim/src/data/site.rs index 57fe387ca6..d611b97950 100644 --- a/rtsim/src/data/site.rs +++ b/rtsim/src/data/site.rs @@ -42,7 +42,7 @@ impl Site { } } -#[derive(Clone, Serialize, Deserialize)] +#[derive(Clone, Default, Serialize, Deserialize)] pub struct Sites { pub sites: HopSlotMap, diff --git a/rtsim/src/event.rs b/rtsim/src/event.rs index c1a32a85e0..65d1ea213e 100644 --- a/rtsim/src/event.rs +++ b/rtsim/src/event.rs @@ -20,6 +20,7 @@ impl Event for OnSetup {} pub struct OnTick { pub time_of_day: TimeOfDay, pub time: Time, + pub tick: u64, pub dt: f32, } impl Event for OnTick {} diff --git a/rtsim/src/gen/mod.rs b/rtsim/src/gen/mod.rs index 4f64d34aea..2dd8e727ca 100644 --- a/rtsim/src/gen/mod.rs +++ b/rtsim/src/gen/mod.rs @@ -44,6 +44,7 @@ impl Data { factions: Default::default(), }, + tick: 0, time_of_day: TimeOfDay(settings.start_time), should_purge: false, }; diff --git a/rtsim/src/lib.rs b/rtsim/src/lib.rs index badf1eebd3..889b3814d8 100644 --- a/rtsim/src/lib.rs +++ b/rtsim/src/lib.rs @@ -150,9 +150,15 @@ impl RtState { time: Time, dt: f32, ) { - self.data_mut().time_of_day = time_of_day; + let tick = { + let mut data = self.data_mut(); + data.time_of_day = time_of_day; + data.tick += 1; + data.tick + }; let event = OnTick { time_of_day, + tick, time, dt, }; diff --git a/rtsim/src/rule/npc_ai.rs b/rtsim/src/rule/npc_ai.rs index 73d49aa6fc..37435a497d 100644 --- a/rtsim/src/rule/npc_ai.rs +++ b/rtsim/src/rule/npc_ai.rs @@ -3,7 +3,7 @@ use std::hash::BuildHasherDefault; use crate::{ ai::{casual, choose, finish, important, just, now, seq, until, Action, NpcCtx}, data::{ - npc::{Brain, PathData}, + npc::{Brain, PathData, SimulationMode}, Sites, }, event::OnTick, @@ -33,6 +33,12 @@ use world::{ IndexRef, World, }; +/// How many ticks should pass between running NPC AI. +/// Note that this only applies to simulated NPCs: loaded NPCs have their AI +/// code run every tick. This means that AI code should be broadly +/// DT-independent. +const SIMULATED_TICK_SKIP: u64 = 10; + pub struct NpcAi; const CARDINALS: &[Vec2] = &[ @@ -209,6 +215,8 @@ impl Rule for NpcAi { let mut data = ctx.state.data_mut(); data.npcs .iter_mut() + // Don't run AI for simulated NPCs every tick + .filter(|(_, npc)| matches!(npc.mode, SimulationMode::Loaded) || (npc.seed as u64 + ctx.event.tick) % SIMULATED_TICK_SKIP == 0) .map(|(npc_id, npc)| { let controller = std::mem::take(&mut npc.controller); let brain = npc.brain.take().unwrap_or_else(|| Brain { @@ -459,7 +467,7 @@ fn socialize() -> impl Action { .nearby(Some(ctx.npc_id), ctx.npc.wpos.xy(), 8.0) .choose(&mut ctx.rng) { - just(move |ctx| ctx.controller.greet(other)).boxed() + just(move |ctx| ctx.controller.say(other, "npc-speech-villager_open")).boxed() } else if ctx.rng.gen_bool(0.0003) { just(|ctx| ctx.controller.do_dance()) .repeat() @@ -506,7 +514,7 @@ fn adventure() -> impl Action { .map(|ws| ctx.index.sites.get(ws).name().to_string()) .unwrap_or_default(); // Travel to the site - important(just(move |ctx| ctx.controller.say(format!("I've spent enough time here, onward to {}!", site_name))) + important(just(move |ctx| ctx.controller.say(None, format!("I've spent enough time here, onward to {}!", site_name))) .then(travel_to_site(tgt_site, 0.6)) // Stop for a few minutes .then(villager(tgt_site).repeat().stop_if(timeout(wait_time))) @@ -596,12 +604,12 @@ fn villager(visiting_site: SiteId) -> impl Action { Some(site2.tile_center_wpos(house.root_tile()).as_()) }) { - just(|ctx| ctx.controller.say("It's dark, time to go home")) + just(|ctx| ctx.controller.say(None, "It's dark, time to go home")) .then(travel_to_point(house_wpos, 0.65)) .debug(|| "walk to house") .then(socialize().repeat().debug(|| "wait in house")) .stop_if(|ctx| DayPeriod::from(ctx.time_of_day.0).is_light()) - .then(just(|ctx| ctx.controller.say("A new day begins!"))) + .then(just(|ctx| ctx.controller.say(None, "A new day begins!"))) .map(|_| ()) .boxed() } else { @@ -627,7 +635,7 @@ fn villager(visiting_site: SiteId) -> impl Action { } else if matches!(ctx.npc.profession, Some(Profession::Hunter)) && ctx.rng.gen_bool(0.8) { if let Some(forest_wpos) = find_forest(ctx) { return casual( - just(|ctx| ctx.controller.say("Time to go hunting!")) + just(|ctx| ctx.controller.say(None, "Time to go hunting!")) .then(travel_to_point(forest_wpos, 0.75)) .debug(|| "walk to forest") .then({ @@ -641,17 +649,33 @@ fn villager(visiting_site: SiteId) -> impl Action { { return casual( just(|ctx| { - ctx.controller.say( - *[ + // Try to direct our speech at nearby actors, if there are any + let (target, phrases) = if ctx.rng.gen_bool(0.3) && let Some(other) = ctx + .state + .data() + .npcs + .nearby(Some(ctx.npc_id), ctx.npc.wpos.xy(), 8.0) + .choose(&mut ctx.rng) + { + (Some(other), &[ + "You there! Are you in need of a new thingamabob?", + "Are you hungry? I'm sure I've got some cheese you can buy", + "You look like you could do with some new armour!", + ][..]) + // Otherwise, resort to generic expressions + } else { + (None, &[ "All my goods are of the highest quality!", "Does anybody want to buy my wares?", - "I've got the best offers in town.", - "Looking for supplies? I've got you covered.", - ] - .iter() - .choose(&mut ctx.rng) - .unwrap(), - ) // Can't fail + "I've got the best offers in town", + "Looking for supplies? I've got you covered", + ][..]) + }; + + ctx.controller.say( + target, + *phrases.iter().choose(&mut ctx.rng).unwrap(), // Can't fail + ); }) .then(idle().repeat().stop_if(timeout(8.0))) .repeat() diff --git a/rtsim/src/rule/simulate_npcs.rs b/rtsim/src/rule/simulate_npcs.rs index 6c2794507b..7bfe9bfab1 100644 --- a/rtsim/src/rule/simulate_npcs.rs +++ b/rtsim/src/rule/simulate_npcs.rs @@ -244,8 +244,7 @@ fn on_tick(ctx: EventCtx) { // Consume NPC actions for action in std::mem::take(&mut npc.controller.actions) { match action { - NpcAction::Greet(_) | NpcAction::Say(_) => {}, /* Currently, just swallow - * interactions */ + NpcAction::Say(_, _) => {}, // Currently, just swallow interactions } } diff --git a/server/src/sys/agent/behavior_tree.rs b/server/src/sys/agent/behavior_tree.rs index c78e9c38d9..c069b530ad 100644 --- a/server/src/sys/agent/behavior_tree.rs +++ b/server/src/sys/agent/behavior_tree.rs @@ -474,36 +474,31 @@ fn set_owner_if_no_target(bdata: &mut BehaviorData) -> bool { fn handle_rtsim_actions(bdata: &mut BehaviorData) -> bool { if let Some(action) = bdata.agent.rtsim_controller.actions.pop_front() { match action { - NpcAction::Greet(actor) => { - if bdata.agent.allowed_to_speak() - && let Some(target) = bdata.read_data.lookup_actor(actor) - { - bdata.agent.target = Some(Target::new( - target, - false, - bdata.read_data.time.0, - false, - bdata.read_data.positions.get(target).map(|p| p.0), - )); - // We're always aware of someone we're talking to - bdata.agent.awareness.set_maximally_aware(); + NpcAction::Say(target, msg) => { + if bdata.agent.allowed_to_speak() { + // Aim the speech toward a target + if let Some(target) = target.and_then(|tgt| bdata.read_data.lookup_actor(tgt)) { + bdata.agent.target = Some(Target::new( + target, + false, + bdata.read_data.time.0, + false, + bdata.read_data.positions.get(target).map(|p| p.0), + )); + // We're always aware of someone we're talking to + bdata.agent.awareness.set_maximally_aware(); + // Start a timer so that we eventually stop interacting + bdata + .agent + .timer + .start(bdata.read_data.time.0, TimerAction::Interact); + bdata.controller.push_action(ControlAction::Stand); + } - bdata.controller.push_action(ControlAction::Stand); bdata.controller.push_utterance(UtteranceKind::Greeting); - bdata - .agent_data - .chat_npc("npc-speech-villager_open", bdata.event_emitter); - // Start a timer so that they eventually stop interacting - bdata - .agent - .timer - .start(bdata.read_data.time.0, TimerAction::Interact); + bdata.agent_data.chat_npc(msg, bdata.event_emitter); } }, - NpcAction::Say(msg) => { - bdata.controller.push_utterance(UtteranceKind::Greeting); - bdata.agent_data.chat_npc(msg, bdata.event_emitter); - }, } true } else {