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}