veloren_rtsim/rule/
simulate_npcs.rs

1use crate::{
2    RtState, Rule, RuleError,
3    data::{Sentiment, npc::SimulationMode},
4    event::{EventCtx, OnHealthChange, OnHelped, OnMountVolume, OnTick},
5};
6use common::{
7    comp::{self, Body, agent::FlightMode},
8    mounting::{Volume, VolumePos},
9    rtsim::{Actor, NpcAction, NpcActivity, NpcInput},
10    terrain::{CoordinateConversions, TerrainChunkSize},
11    vol::RectVolSize,
12};
13use slotmap::SecondaryMap;
14use vek::{Clamp, Vec2};
15
16pub struct SimulateNpcs;
17
18impl Rule for SimulateNpcs {
19    fn start(rtstate: &mut RtState) -> Result<Self, RuleError> {
20        rtstate.bind(on_helped);
21        rtstate.bind(on_health_changed);
22        rtstate.bind(on_mount_volume);
23        rtstate.bind(on_tick);
24
25        Ok(Self)
26    }
27}
28
29fn on_mount_volume(ctx: EventCtx<SimulateNpcs, OnMountVolume>) {
30    let data = &mut *ctx.state.data_mut();
31
32    // TODO: Add actor to riders.
33    if let VolumePos {
34        kind: Volume::Entity(vehicle),
35        ..
36    } = ctx.event.pos
37        && let Some(link) = data.npcs.mounts.get_steerer_link(vehicle)
38        && let Actor::Npc(driver) = link.rider
39        && let Some(driver) = data.npcs.get_mut(driver)
40    {
41        driver.controller.actions.push(NpcAction::Say(
42            Some(ctx.event.actor),
43            comp::Content::localized("npc-speech-welcome-aboard"),
44        ))
45    }
46}
47
48fn on_health_changed(ctx: EventCtx<SimulateNpcs, OnHealthChange>) {
49    let data = &mut *ctx.state.data_mut();
50
51    if let Some(cause) = ctx.event.cause
52        && let Actor::Npc(npc) = ctx.event.actor
53        && let Some(npc) = data.npcs.get_mut(npc)
54    {
55        if ctx.event.change < 0.0 {
56            npc.sentiments
57                .toward_mut(cause)
58                .change_by(-0.1, Sentiment::ENEMY);
59        } else if ctx.event.change > 0.0 {
60            npc.sentiments
61                .toward_mut(cause)
62                .change_by(0.05, Sentiment::POSITIVE);
63        }
64    }
65}
66
67fn on_helped(ctx: EventCtx<SimulateNpcs, OnHelped>) {
68    let data = &mut *ctx.state.data_mut();
69
70    if let Some(saver) = ctx.event.saver
71        && let Actor::Npc(npc) = ctx.event.actor
72        && let Some(npc) = data.npcs.get_mut(npc)
73    {
74        npc.controller.actions.push(NpcAction::Say(
75            Some(ctx.event.actor),
76            comp::Content::localized("npc-speech-thank_you"),
77        ));
78        npc.sentiments
79            .toward_mut(saver)
80            .change_by(0.3, Sentiment::FRIEND);
81    }
82}
83
84fn on_tick(ctx: EventCtx<SimulateNpcs, OnTick>) {
85    let data = &mut *ctx.state.data_mut();
86
87    // Maintain links
88    let ids = data.npcs.mounts.ids().collect::<Vec<_>>();
89    let mut mount_activity = SecondaryMap::new();
90    for link_id in ids {
91        if let Some(link) = data.npcs.mounts.get(link_id) {
92            if let Some(mount) = data
93                .npcs
94                .npcs
95                .get(link.mount)
96                .filter(|mount| !mount.is_dead())
97            {
98                let wpos = mount.wpos;
99                if let Actor::Npc(rider) = link.rider {
100                    if let Some(rider) = data
101                        .npcs
102                        .npcs
103                        .get_mut(rider)
104                        .filter(|rider| !rider.is_dead())
105                    {
106                        rider.wpos = wpos;
107                        mount_activity.insert(link.mount, rider.controller.activity);
108                    } else {
109                        data.npcs.mounts.dismount(link.rider)
110                    }
111                }
112            } else {
113                data.npcs.mounts.remove_mount(link.mount)
114            }
115        }
116    }
117
118    let mut npc_inputs = Vec::new();
119
120    for (npc_id, npc) in data.npcs.npcs.iter_mut().filter(|(_, npc)| !npc.is_dead()) {
121        npc.controller.actions.retain(|action| match action {
122            // NPC-to-NPC messages never leave rtsim
123            NpcAction::Msg { to, msg } => {
124                if let Actor::Npc(to) = to {
125                    npc_inputs.push((*to, NpcInput::Msg {
126                        from: npc_id.into(),
127                        msg: msg.clone(),
128                    }));
129                } else {
130                    // TODO: Send to players?
131                }
132                false
133            },
134            // All other cases are handled by the game when loaded
135            NpcAction::Say(_, _) | NpcAction::Attack(_) | NpcAction::Dialogue(_, _) => {
136                matches!(npc.mode, SimulationMode::Loaded)
137            },
138        });
139
140        if matches!(npc.mode, SimulationMode::Simulated) {
141            let activity = if data.npcs.mounts.get_mount_link(npc_id).is_some() {
142                // We are riding, nothing to do.
143                continue;
144            } else if let Some(activity) = mount_activity.get(npc_id) {
145                *activity
146            } else {
147                npc.controller.activity
148            };
149
150            match activity {
151                // Move NPCs if they have a target destination
152                Some(NpcActivity::Goto(target, speed_factor)) => {
153                    let diff = target - npc.wpos;
154                    let dist2 = diff.magnitude_squared();
155
156                    if dist2 > 0.5f32.powi(2) {
157                        let offset = diff
158                            * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
159                                / dist2.sqrt())
160                            .min(1.0);
161                        let new_wpos = npc.wpos + offset;
162
163                        let is_valid = match npc.body {
164                            // Don't move water bound bodies outside of water.
165                            Body::Ship(comp::ship::Body::SailBoat | comp::ship::Body::Galleon)
166                            | Body::FishMedium(_)
167                            | Body::FishSmall(_) => {
168                                let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
169                                ctx.world
170                                    .sim()
171                                    .get(chunk_pos)
172                                    .is_none_or(|f| f.river.river_kind.is_some())
173                            },
174                            Body::Ship(comp::ship::Body::DefaultAirship) => false,
175                            _ => true,
176                        };
177
178                        if is_valid {
179                            npc.wpos = new_wpos;
180                        }
181
182                        npc.dir = (target.xy() - npc.wpos.xy())
183                            .try_normalized()
184                            .unwrap_or(npc.dir);
185                    }
186                },
187                // Move Flying NPCs like airships if they have a target destination
188                Some(NpcActivity::GotoFlying(target, speed_factor, height, dir, mode)) => {
189                    let diff = target - npc.wpos;
190                    let dist2 = diff.magnitude_squared();
191
192                    if dist2 > 0.5f32.powi(2) {
193                        match npc.body {
194                            Body::Ship(comp::ship::Body::DefaultAirship) => {
195                                // RTSim NPCs don't interract with terrain, and their position is
196                                // independent of ground level.
197                                // While movement is simulated, airships will happily stay at ground
198                                // level or fly through mountains.
199                                // The code at the end of this block "Make sure NPCs remain in a
200                                // valid location" just forces
201                                // airships to be at least above ground (on the ground actually).
202                                // The reason is that when docking, airships need to descend much
203                                // closer to the terrain
204                                // than when cruising between sites, so airships cannot be forced to
205                                // stay at a fixed height above
206                                // terrain (i.e. flying_height()). Instead, when mode is
207                                // FlightMode::FlyThrough, set the airship altitude directly to
208                                // terrain height + height (if Some)
209                                // or terrain height + default height (npc.body.flying_height()).
210                                // When mode is FlightMode::Braking, the airship is allowed to
211                                // descend below flying height
212                                // because it is near or at the dock. In this mode, if height is
213                                // Some, set the airship altitude to
214                                // the maximum of target.z or terrain height + height. If height is
215                                // None, set the airship altitude to
216                                // target.z. By forcing the airship altitude to be at a specific
217                                // value, when the airship is
218                                // suddenly in a loaded chunk it will not be below or at the ground
219                                // and will not get stuck.
220
221                                // Move in x,y
222                                let diffxy = target.xy() - npc.wpos.xy();
223                                let distxy2 = diffxy.magnitude_squared();
224                                if distxy2 > 0.5f32.powi(2) {
225                                    let offsetxy = diffxy
226                                        * (npc.body.max_speed_approx()
227                                            * speed_factor
228                                            * ctx.event.dt
229                                            / distxy2.sqrt());
230                                    npc.wpos.x += offsetxy.x;
231                                    npc.wpos.y += offsetxy.y;
232                                }
233                                // The diff is not computed for z like x,y. Rather, the altitude is
234                                // set directly so that when the
235                                // simulated ship is suddenly in a loaded chunk it will not be below
236                                // or at the ground level and risk getting stuck.
237                                let base_height =
238                                    if mode == FlightMode::FlyThrough || height.is_some() {
239                                        ctx.world.sim().get_surface_alt_approx(npc.wpos.xy().as_())
240                                    } else {
241                                        0.0
242                                    };
243                                let ship_z = match mode {
244                                    FlightMode::FlyThrough => {
245                                        base_height + height.unwrap_or(npc.body.flying_height())
246                                    },
247                                    FlightMode::Braking(_) => {
248                                        (base_height + height.unwrap_or(0.0)).max(target.z)
249                                    },
250                                };
251                                npc.wpos.z = ship_z;
252                            },
253                            _ => {
254                                let offset = diff
255                                    * (npc.body.max_speed_approx() * speed_factor * ctx.event.dt
256                                        / dist2.sqrt())
257                                    .min(1.0);
258                                let new_wpos = npc.wpos + offset;
259
260                                let is_valid = match npc.body {
261                                    // Don't move water bound bodies outside of water.
262                                    Body::Ship(
263                                        comp::ship::Body::SailBoat | comp::ship::Body::Galleon,
264                                    )
265                                    | Body::FishMedium(_)
266                                    | Body::FishSmall(_) => {
267                                        let chunk_pos = new_wpos.xy().as_().wpos_to_cpos();
268                                        ctx.world
269                                            .sim()
270                                            .get(chunk_pos)
271                                            .is_none_or(|f| f.river.river_kind.is_some())
272                                    },
273                                    _ => true,
274                                };
275
276                                if is_valid {
277                                    npc.wpos = new_wpos;
278                                }
279                            },
280                        }
281
282                        if let Some(dir_override) = dir {
283                            npc.dir = dir_override.xy().try_normalized().unwrap_or(npc.dir);
284                        } else {
285                            npc.dir = (target.xy() - npc.wpos.xy())
286                                .try_normalized()
287                                .unwrap_or(npc.dir);
288                        }
289                    }
290                },
291                Some(
292                    NpcActivity::Gather(_)
293                    | NpcActivity::HuntAnimals
294                    | NpcActivity::Dance(_)
295                    | NpcActivity::Cheer(_)
296                    | NpcActivity::Sit(..)
297                    | NpcActivity::Talk(..),
298                ) => {
299                    // TODO: Maybe they should walk around randomly
300                    // when gathering resources?
301                },
302                None => {},
303            }
304
305            // Make sure NPCs remain in a valid location
306            let clamped_wpos = npc.wpos.xy().clamped(
307                Vec2::zero(),
308                (ctx.world.sim().get_size() * TerrainChunkSize::RECT_SIZE).as_(),
309            );
310            match npc.body {
311                // Don't force air ships to be at flying_height, else they can't land at docks.
312                Body::Ship(comp::ship::Body::DefaultAirship | comp::ship::Body::AirBalloon) => {
313                    npc.wpos = clamped_wpos.with_z(
314                        ctx.world
315                            .sim()
316                            .get_surface_alt_approx(clamped_wpos.as_())
317                            .max(npc.wpos.z),
318                    );
319                },
320                _ => {
321                    npc.wpos = clamped_wpos.with_z(
322                        ctx.world.sim().get_surface_alt_approx(clamped_wpos.as_())
323                            + npc.body.flying_height(),
324                    );
325                },
326            }
327        }
328
329        // Move home if required
330        if let Some(new_home) = npc.controller.new_home.take() {
331            // Remove the NPC from their old home population
332            if let Some(old_home) = npc.home
333                && let Some(old_home) = data.sites.get_mut(old_home)
334            {
335                old_home.population.remove(&npc_id);
336            }
337            // Add the NPC to their new home population
338            if let Some(new_home) = new_home
339                && let Some(new_home) = data.sites.get_mut(new_home)
340            {
341                new_home.population.insert(npc_id);
342            }
343            npc.home = new_home;
344        }
345
346        // Create registered quests
347        for (id, quest) in core::mem::take(&mut npc.controller.quests_to_create) {
348            data.quests.create(id, quest);
349        }
350
351        // Set job status
352        npc.job = npc.controller.job.clone();
353    }
354
355    for (npc_id, input) in npc_inputs {
356        if let Some(npc) = data.npcs.get_mut(npc_id) {
357            npc.inbox.push_back(input);
358        }
359    }
360}