Skip to main content

veloren_rtsim/rule/
simulate_npcs.rs

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