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}