veloren_rtsim/rule/
simulate_npcs.rs

1use crate::{
2    RtState, Rule, RuleError,
3    data::{Npc, npc::SimulationMode},
4    event::{EventCtx, OnDeath, OnMountVolume, OnTick},
5};
6use common::{
7    comp::{self, Body},
8    mounting::{Volume, VolumePos},
9    rtsim::{Actor, NpcAction, NpcActivity, Personality},
10    terrain::{CoordinateConversions, TerrainChunkSize},
11    vol::RectVolSize,
12};
13use rand::prelude::*;
14use rand_chacha::ChaChaRng;
15use slotmap::SecondaryMap;
16use tracing::{error, warn};
17use vek::{Clamp, Vec2};
18use world::{CONFIG, site::SiteKind};
19
20pub struct SimulateNpcs;
21
22impl Rule for SimulateNpcs {
23    fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
24        rtstate.bind(on_death);
25        rtstate.bind(on_tick);
26        rtstate.bind(on_mount_volume);
27
28        Ok(Self)
29    }
30}
31
32fn on_mount_volume(ctx: EventCtx<SimulateNpcs, OnMountVolume>) {
33    let data = &mut *ctx.state.data_mut();
34
35    // TODO: Add actor to riders.
36    if let VolumePos {
37        kind: Volume::Entity(vehicle),
38        ..
39    } = ctx.event.pos
40        && let Some(link) = data.npcs.mounts.get_steerer_link(vehicle)
41        && let Actor::Npc(driver) = link.rider
42        && let Some(driver) = data.npcs.get_mut(driver)
43    {
44        driver.controller.actions.push(NpcAction::Say(
45            Some(ctx.event.actor),
46            comp::Content::localized("npc-speech-welcome-aboard"),
47        ))
48    }
49}
50
51fn on_death(ctx: EventCtx<SimulateNpcs, OnDeath>) {
52    let data = &mut *ctx.state.data_mut();
53
54    if let Actor::Npc(npc_id) = ctx.event.actor {
55        if let Some(npc) = data.npcs.get(npc_id) {
56            let mut rng = ChaChaRng::from_seed(thread_rng().gen::<[u8; 32]>());
57
58            // Respawn dead NPCs
59            let details = match npc.body {
60                Body::Humanoid(_) => {
61                    if let Some((site_id, site)) = data
62                        .sites
63                        .iter()
64                        .filter(|(id, site)| {
65                            // Don't respawn in the same town
66                            Some(*id) != npc.home
67                                && site.world_site.is_some_and(|s| {
68                                    matches!(
69                                        ctx.index.sites.get(s).kind,
70                                        SiteKind::Refactor(_)
71                                            | SiteKind::CliffTown(_)
72                                            | SiteKind::SavannahTown(_)
73                                            | SiteKind::CoastalTown(_)
74                                            | SiteKind::DesertCity(_)
75                                    )
76                                })
77                        })
78                        .min_by_key(|(_, site)| site.population.len())
79                    {
80                        let rand_wpos = |rng: &mut ChaChaRng| {
81                            let wpos2d = site.wpos.map(|e| e + rng.gen_range(-10..10));
82                            wpos2d
83                                .map(|e| e as f32 + 0.5)
84                                .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
85                        };
86                        let random_humanoid = |rng: &mut ChaChaRng| {
87                            let species = comp::humanoid::ALL_SPECIES.choose(&mut *rng).unwrap();
88                            Body::Humanoid(comp::humanoid::Body::random_with(rng, species))
89                        };
90                        let npc_id = data.spawn_npc(
91                            Npc::new(
92                                rng.gen(),
93                                rand_wpos(&mut rng),
94                                random_humanoid(&mut rng),
95                                npc.role.clone(),
96                            )
97                            .with_personality(Personality::random(&mut rng))
98                            .with_home(site_id)
99                            .with_faction(npc.faction),
100                        );
101                        Some((npc_id, Some(site_id)))
102                    } else {
103                        warn!("No site found for respawning humanoid");
104                        None
105                    }
106                },
107                body => {
108                    let home = npc.home.and_then(|_| {
109                        data.sites
110                            .iter()
111                            .filter(|(id, site)| {
112                                Some(*id) != npc.home
113                                    && site.world_site.is_some_and(|s| {
114                                        matches!(
115                                            ctx.index.sites.get(s).kind,
116                                            SiteKind::Terracotta(_)
117                                                | SiteKind::Haniwa(_)
118                                                | SiteKind::Myrmidon(_)
119                                                | SiteKind::Adlet(_)
120                                                | SiteKind::DwarvenMine(_)
121                                                | SiteKind::ChapelSite(_)
122                                                | SiteKind::Cultist(_)
123                                                | SiteKind::Gnarling(_)
124                                                | SiteKind::Sahagin(_)
125                                                | SiteKind::VampireCastle(_),
126                                        )
127                                    })
128                            })
129                            .min_by_key(|(_, site)| site.population.len())
130                    });
131
132                    let wpos = if let Some((_, home)) = home {
133                        let wpos2d = home.wpos.map(|e| e + rng.gen_range(-10..10));
134                        wpos2d
135                            .map(|e| e as f32 + 0.5)
136                            .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
137                    } else {
138                        let is_gigas = matches!(body, Body::BipedLarge(body) if body.species == comp::body::biped_large::Species::Gigasfrost);
139
140                        let pos = (0..(if is_gigas {
141                            /* More attempts for gigas */
142                            100
143                        } else {
144                            10
145                        }))
146                            .map(|_| {
147                                ctx.world
148                                    .sim()
149                                    .get_size()
150                                    .map(|sz| rng.gen_range(0..sz as i32))
151                            })
152                            .find(|pos| {
153                                ctx.world.sim().get(*pos).is_some_and(|c| {
154                                    !c.is_underwater() && (!is_gigas || c.temp < CONFIG.snow_temp)
155                                })
156                            })
157                            .unwrap_or(ctx.world.sim().get_size().as_() / 2);
158                        let wpos2d = pos.cpos_to_wpos_center();
159                        wpos2d
160                            .map(|e| e as f32 + 0.5)
161                            .with_z(ctx.world.sim().get_alt_approx(wpos2d).unwrap_or(0.0))
162                    };
163
164                    let home = home.map(|(site_id, _)| site_id);
165
166                    let npc_id = data.npcs.create_npc(
167                        Npc::new(rng.gen(), wpos, body, npc.role.clone()).with_home(home),
168                    );
169                    Some((npc_id, home))
170                },
171            };
172
173            // Add the NPC to their home site
174            if let Some((npc_id, Some(home_site))) = details {
175                if let Some(home) = data.sites.get_mut(home_site) {
176                    home.population.insert(npc_id);
177                }
178            }
179        } else {
180            error!("Trying to respawn non-existent NPC");
181        }
182    }
183}
184
185fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
186    let data = &mut *ctx.state.data_mut();
187
188    // Maintain links
189    let ids = data.npcs.mounts.ids().collect::<Vec<_>>();
190    let mut mount_activity = SecondaryMap::new();
191    for link_id in ids {
192        if let Some(link) = data.npcs.mounts.get(link_id) {
193            if let Some(mount) = data
194                .npcs
195                .npcs
196                .get(link.mount)
197                .filter(|mount| !mount.is_dead())
198            {
199                let wpos = mount.wpos;
200                if let Actor::Npc(rider) = link.rider {
201                    if let Some(rider) = data
202                        .npcs
203                        .npcs
204                        .get_mut(rider)
205                        .filter(|rider| !rider.is_dead())
206                    {
207                        rider.wpos = wpos;
208                        mount_activity.insert(link.mount, rider.controller.activity);
209                    } else {
210                        data.npcs.mounts.dismount(link.rider)
211                    }
212                }
213            } else {
214                data.npcs.mounts.remove_mount(link.mount)
215            }
216        }
217    }
218
219    for (npc_id, npc) in data.npcs.npcs.iter_mut().filter(|(_, npc)| !npc.is_dead()) {
220        if matches!(npc.mode, SimulationMode::Simulated) {
221            // Consume NPC actions
222            for action in std::mem::take(&mut npc.controller.actions) {
223                match action {
224                    NpcAction::Say(_, _) => {}, // Currently, just swallow interactions
225                    NpcAction::Attack(_) => {}, // TODO: Implement simulated combat
226                    NpcAction::Dialogue(_, _) => {},
227                }
228            }
229
230            let activity = if data.npcs.mounts.get_mount_link(npc_id).is_some() {
231                // We are riding, nothing to do.
232                continue;
233            } else if let Some(activity) = mount_activity.get(npc_id) {
234                *activity
235            } else {
236                npc.controller.activity
237            };
238
239            match activity {
240                // Move NPCs if they have a target destination
241                Some(NpcActivity::Goto(target, speed_factor)) => {
242                    let diff = target - npc.wpos;
243                    let dist2 = diff.magnitude_squared();
244
245                    if dist2 > 0.5f32.powi(2) {
246                        let offset = diff
247                            * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
248                                / dist2.sqrt())
249                            .min(1.0);
250                        let new_wpos = npc.wpos + offset;
251
252                        let is_valid = match npc.body {
253                            // Don't move water bound bodies outside of water.
254                            Body::Ship(comp::ship::Body::SailBoat | comp::ship::Body::Galleon)
255                            | Body::FishMedium(_)
256                            | Body::FishSmall(_) => {
257                                let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
258                                ctx.world
259                                    .sim()
260                                    .get(chunk_pos)
261                                    .is_none_or(|f| f.river.river_kind.is_some())
262                            },
263                            Body::Ship(comp::ship::Body::DefaultAirship) => false,
264                            _ => true,
265                        };
266
267                        if is_valid {
268                            npc.wpos = new_wpos;
269                        }
270
271                        npc.dir = (target.xy() - npc.wpos.xy())
272                            .try_normalized()
273                            .unwrap_or(npc.dir);
274                    }
275                },
276                // Move Flying NPCs like airships if they have a target destination
277                Some(NpcActivity::GotoFlying(target, speed_factor, _, dir, _)) => {
278                    let diff = target - npc.wpos;
279                    let dist2 = diff.magnitude_squared();
280
281                    if dist2 > 0.5f32.powi(2) {
282                        match npc.body {
283                            Body::Ship(comp::ship::Body::DefaultAirship) => {
284                                // Don't limit airship movement to 1.0 per axis
285                                // SimulationMode::Simulated treats the Npc dimensions differently
286                                // somehow from when the Npc is
287                                // loaded. The calculation below results in a position
288                                // that is roughly the offset from the ship centerline to the
289                                // docking position and is also off
290                                // by the offset from the ship fore/aft centerline to the docking
291                                // position. The result is that if the player spawns in at a dock
292                                // where an airship is docked, the
293                                // airship will bounce around while seeking the docking position
294                                // in loaded mode.
295                                let offset = (diff - npc.body.mount_offset())
296                                    * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
297                                        / dist2.sqrt());
298                                npc.wpos += offset;
299                            },
300                            _ => {
301                                let offset = diff
302                                    * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
303                                        / dist2.sqrt())
304                                    .min(1.0);
305                                let new_wpos = npc.wpos + offset;
306
307                                let is_valid = match npc.body {
308                                    // Don't move water bound bodies outside of water.
309                                    Body::Ship(
310                                        comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
311                                    )
312                                    | Body::FishMedium(_)
313                                    | Body::FishSmall(_) => {
314                                        let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
315                                        ctx.world
316                                            .sim()
317                                            .get(chunk_pos)
318                                            .is_none_or(|f| f.river.river_kind.is_some())
319                                    },
320                                    _ => true,
321                                };
322
323                                if is_valid {
324                                    npc.wpos = new_wpos;
325                                }
326                            },
327                        }
328
329                        if let Some(dir_override) = dir {
330                            npc.dir = dir_override.xy().try_normalized().unwrap_or(npc.dir);
331                        } else {
332                            npc.dir = (target.xy() - npc.wpos.xy())
333                                .try_normalized()
334                                .unwrap_or(npc.dir);
335                        }
336                    }
337                },
338                Some(
339                    NpcActivity::Gather(_)
340                    | NpcActivity::HuntAnimals
341                    | NpcActivity::Dance(_)
342                    | NpcActivity::Cheer(_)
343                    | NpcActivity::Sit(..)
344                    | NpcActivity::Talk(..),
345                ) => {
346                    // TODO: Maybe they should walk around randomly
347                    // when gathering resources?
348                },
349                None => {},
350            }
351
352            // Make sure NPCs remain in a valid location
353            let clamped_wpos = npc.wpos.xy().clamped(
354                Vec2::zero(),
355                (ctx.world.sim().get_size() * TerrainChunkSize::RECT_SIZE).as_(),
356            );
357            match npc.body {
358                Body::Ship(comp::ship::Body::DefaultAirship | comp::ship::Body::AirBalloon) => {
359                    npc.wpos = clamped_wpos.with_z(
360                        ctx.world
361                            .sim()
362                            .get_surface_alt_approx(clamped_wpos.as_())
363                            .max(npc.wpos.z),
364                    );
365                },
366                _ => {
367                    npc.wpos = clamped_wpos.with_z(
368                        ctx.world.sim().get_surface_alt_approx(clamped_wpos.as_())
369                            + npc.body.flying_height(),
370                    );
371                },
372            }
373        }
374
375        // Move home if required
376        if let Some(new_home) = npc.controller.new_home.take() {
377            // Remove the NPC from their old home population
378            if let Some(old_home) = npc.home {
379                if let Some(old_home) = data.sites.get_mut(old_home) {
380                    old_home.population.remove(&npc_id);
381                }
382            }
383            // Add the NPC to their new home population
384            if let Some(new_home) = data.sites.get_mut(new_home) {
385                new_home.population.insert(npc_id);
386            }
387            npc.home = Some(new_home);
388        }
389
390        // Set hired status if required (I'm a poet and I didn't know it)
391        if let Some(hiring) = npc.controller.hiring.take() {
392            npc.hiring = hiring;
393        }
394    }
395}