veloren_rtsim/rule/npc_ai/airship_ai.rs
1use crate::{
2 ai::{Action, NpcCtx, State, finish, just, now, predicate::timeout},
3 data::npc::SimulationMode,
4};
5use common::{
6 comp::{
7 self, Content,
8 agent::{BrakingMode, FlightMode},
9 compass::Direction,
10 },
11 rtsim::NpcId,
12 store::Id,
13 util::Dir,
14};
15use rand::prelude::*;
16use std::{cmp::Ordering, time::Duration};
17use vek::*;
18use world::{
19 civ::airship_travel::{AirshipDockingApproach, Airships},
20 site::Site,
21 util::{CARDINALS, DHashMap},
22};
23
24#[cfg(debug_assertions)] use tracing::debug;
25
26/// Airships can slow down or hold position to avoid collisions with other
27/// airships.
28#[derive(Debug, Copy, Clone, Default, PartialEq)]
29enum AirshipAvoidanceMode {
30 #[default]
31 None,
32 Hold(Vec3<f32>, Dir),
33 SlowDown,
34 Stuck(Vec3<f32>),
35}
36
37// Context data for the airship route.
38// This is the context data for the pilot_airship action.
39#[derive(Debug, Default, Clone)]
40struct AirshipRouteContext {
41 // The current approach index, 0 1 or none
42 current_approach_index: Option<usize>,
43 // The route's site ids.
44 site_ids: Option<[Id<Site>; 2]>,
45
46 // For fly_airship action
47 // For determining the velocity and direction of this and other airships on the route.
48 trackers: DHashMap<NpcId, ZoneDistanceTracker>,
49 // For tracking the airship's position history to determine if the airship is stuck.
50 pos_trackers: DHashMap<NpcId, PositionTracker>,
51 // Timer for checking the airship trackers.
52 avoidance_timer: Duration,
53 // Timer used when holding, either on approach or at the dock.
54 hold_timer: f32,
55 // Whether the initial hold message has been sent to the client.
56 hold_announced: bool,
57 // The original speed factor passed to the fly_airship action.
58 speed_factor: f32,
59 // The current avoidance mode for the airship.
60 avoid_mode: AirshipAvoidanceMode,
61 // Whether the airship had to hold during the last flight.
62 did_hold: bool,
63 // number of times the airship had to slow down during the last flight.
64 slow_count: u32,
65 // The extra docking time due to holding.
66 extra_hold_dock_time: f32,
67 // The extra docking time due to holding.
68 extra_slowdown_dock_time: f32,
69}
70
71/// Tracks the airship position history.
72/// Used for determining if an airship is stuck.
73#[derive(Debug, Default, Clone)]
74struct PositionTracker {
75 // The airship's position history. Used for determining if the airship is stuck in one place.
76 pos_history: Vec<Vec3<f32>>,
77 // The route to follow for backing out of a stuck position.
78 backout_route: Vec<Vec3<f32>>,
79}
80
81impl PositionTracker {
82 const BACKOUT_TARGET_DIST: f32 = 50.0;
83 const MAX_POS_HISTORY_SIZE: usize = 5;
84
85 // Add a new position to the position history, maintaining a fixed size.
86 fn add_position(&mut self, new_pos: Vec3<f32>) {
87 if self.pos_history.len() >= PositionTracker::MAX_POS_HISTORY_SIZE {
88 self.pos_history.remove(0);
89 }
90 self.pos_history.push(new_pos);
91 }
92
93 // Check if the airship is stuck in one place.
94 fn is_stuck(&mut self, ctx: &mut NpcCtx, approach: &AirshipDockingApproach) -> bool {
95 // The position history must be full to determine if the airship is stuck.
96 if self.pos_history.len() == PositionTracker::MAX_POS_HISTORY_SIZE
97 && self.backout_route.is_empty()
98 {
99 if let Some(last_pos) = self.pos_history.last() {
100 // If all the positions in the history are within 10 of the last position,
101 if self
102 .pos_history
103 .iter()
104 .all(|pos| pos.distance_squared(*last_pos) < 10.0)
105 {
106 // Airship is stuck on some obstacle.
107 // If current position is near the ground, backout while gaining altitude
108 // else backout at the current height and then ascend to clear the obstacle.
109 // This function is only called in cruise phase, so the direction to backout
110 // will be the opposite of the direction from the current pos to the approach
111 // initial pos.
112 if let Some(backout_dir) = (ctx.npc.wpos.xy() - approach.approach_initial_pos)
113 .with_z(0.0)
114 .try_normalized()
115 {
116 let ground = ctx
117 .world
118 .sim()
119 .get_surface_alt_approx(last_pos.xy().map(|e| e as i32));
120 let mut backout_pos = ctx.npc.wpos + backout_dir * 100.0;
121 if (ctx.npc.wpos.z - ground).abs() < 10.0 {
122 backout_pos.z += 50.0;
123 }
124 self.backout_route =
125 vec![backout_pos, backout_pos + Vec3::unit_z() * 200.0];
126 // The airship is stuck.
127 #[cfg(debug_assertions)]
128 debug!(
129 "Airship {} Stuck! at {:?}, backout_dir:{:?}, backout_pos:{:?}",
130 format!("{:?}", ctx.npc_id),
131 ctx.npc.wpos,
132 backout_dir,
133 backout_pos
134 );
135 }
136 }
137 }
138 }
139 !self.backout_route.is_empty()
140 }
141
142 fn next_backout_pos(&mut self, ctx: &mut NpcCtx) -> Option<Vec3<f32>> {
143 if let Some(pos) = self.backout_route.first().cloned() {
144 if ctx.npc.wpos.distance_squared(pos) < PositionTracker::BACKOUT_TARGET_DIST.powi(2) {
145 self.backout_route.remove(0);
146 }
147 Some(pos)
148 } else {
149 self.pos_history.clear();
150 None
151 }
152 }
153}
154
155#[derive(Debug, PartialEq)]
156enum DistanceTrend {
157 ApproachingDock,
158 DepartingDock,
159 Docked,
160}
161
162#[derive(Debug, PartialEq)]
163enum DistanceZone {
164 InsideReference,
165 InsideMyDistance,
166 OutsideMyDistance,
167}
168
169/// Tracks airship distance trend from a fixed position. Used for airship
170/// traffic control.
171#[derive(Debug, Default, Clone)]
172struct ZoneDistanceTracker {
173 fixed_pos: Vec2<f32>,
174 stable_tolerance: f32,
175 ref_dist: Option<f32>,
176 prev_dist: Option<f32>,
177 avg_dist: Option<f32>,
178}
179
180impl ZoneDistanceTracker {
181 fn update(
182 &mut self,
183 mypos: Vec2<f32>,
184 otherpos: Vec2<f32>,
185 ) -> (Option<DistanceTrend>, Option<DistanceZone>) {
186 const SMOOTHING_FACTOR: f32 = 0.3; // Damp out some fluctuations so that we know if the trend is stable.
187 // There is no delta time here because the measurements are taken at larger time
188 // intervals, on the order of seconds.
189 let current_dist = otherpos.distance_squared(self.fixed_pos);
190 let trend = if let Some(prev_dist) = self.prev_dist {
191 let my_dist = mypos.distance_squared(self.fixed_pos);
192 let avg_dist = self
193 .avg_dist
194 .map(|prev_avg_dist| Lerp::lerp(prev_avg_dist, current_dist, SMOOTHING_FACTOR))
195 .unwrap_or(current_dist);
196 self.avg_dist = Some(avg_dist);
197 let zone = if current_dist < my_dist {
198 if let Some(ref_dist) = self.ref_dist {
199 if current_dist < ref_dist {
200 DistanceZone::InsideReference
201 } else {
202 DistanceZone::InsideMyDistance
203 }
204 } else {
205 DistanceZone::InsideMyDistance
206 }
207 } else {
208 DistanceZone::OutsideMyDistance
209 };
210 if avg_dist.abs() < self.stable_tolerance {
211 (Some(DistanceTrend::Docked), Some(zone))
212 } else if current_dist < prev_dist {
213 (Some(DistanceTrend::ApproachingDock), Some(zone))
214 } else {
215 (Some(DistanceTrend::DepartingDock), Some(zone))
216 }
217 } else {
218 self.avg_dist = Some(current_dist);
219 (None, None)
220 };
221 self.prev_dist = Some(current_dist);
222 trend
223 }
224}
225
226/// The flight phases of an airship.
227/// When the airship is loaded from RTSim data there is no "current approach
228/// index", so the 'Reset' phases are used to get the airship back on the route.
229/// The non-'Reset' phases are used for the normal flight loop.
230#[derive(Debug, Copy, Clone, PartialEq, Default)]
231enum AirshipFlightPhase {
232 Ascent,
233 Cruise,
234 ApproachFinal,
235 Transition,
236 // Docking,
237 #[default]
238 Docked,
239}
240
241const AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA: f32 = 100.0;
242const AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA: f32 = 150.0;
243
244/// Wrapper for the fly_airship action, so the route context fields can be
245/// reset.
246fn fly_airship(
247 phase: AirshipFlightPhase,
248 wpos: Vec3<f32>,
249 goal_dist: f32,
250 initial_speed_factor: f32,
251 height_offset: f32,
252 with_terrain_following: bool,
253 direction_override: Option<Dir>,
254 flight_mode: FlightMode,
255 with_collision_avoidance: bool,
256 radar_interval: Duration,
257) -> impl Action<AirshipRouteContext> {
258 now(move |_, airship_context: &mut AirshipRouteContext| {
259 airship_context.speed_factor = initial_speed_factor;
260 airship_context.avoidance_timer = radar_interval;
261 airship_context.avoid_mode = AirshipAvoidanceMode::None;
262 airship_context.trackers.clear();
263 fly_airship_inner(
264 phase,
265 wpos,
266 goal_dist,
267 initial_speed_factor,
268 height_offset,
269 with_terrain_following,
270 direction_override,
271 flight_mode,
272 with_collision_avoidance,
273 radar_interval,
274 )
275 })
276}
277
278/// Called from pilot_airship to move the airship along phases of the route
279/// and for initial routing after server startup. The bulk of this action
280/// is collision-avoidance monitoring. The frequency of the collision-avoidance
281/// tests is controlled by the radar_interval parameter.
282/// The collision-avoidance logic has the ability to change the airship's speed
283/// and to hold position.
284///
285/// # Avoidance Logic
286/// All airships on the same route follow what is essentially a race track.
287/// All collision issues are caused by airships catching up to each other on the
288/// route. To avoid collisions, the postion and movement of other airships on
289/// the route is monitored at a interval of between 2 and 4 seconds. If another
290/// airship is moving towards the same docking position, it may be ahead or
291/// behind the monitoring airship. If the other airship is ahead, and the
292/// monitoring airship is approaching the docking position, the monitoring
293/// airship will slow down or stop to avoid a potential conflict.
294///
295/// # Parameters
296///
297/// - `route_context`: The AirshipRouteContext owned by the pilot_airship
298/// action.
299/// - `phase`: One of the phases of the airship flight loop or reset stages.
300/// - `wpos`: The fly-to target position for the airship.
301/// - `goal_dist`: The distance to the target position at which this action
302/// (this phase) will stop.
303/// - `initial_speed_factor`: The initial speed factor for the airship. Can be
304/// modified by collision-avoidance.
305/// - `height_offset`: The height offset for the airship (height above terrain).
306/// This is only used if with_terrain_following is true.
307/// - `with_terrain_following`: Whether to follow the terrain. If true, the
308/// airship will fly at a height above the terrain. If false, the airship
309/// flies directly to the target position.
310/// - `direction_override`: An optional direction override for the airship. If
311/// Some, the airship will be oriented (pointed) in this direction.
312/// - `flight_mode`: Influences the positioning of the airship. When approaching
313/// or at the target position, the airship either slows (brakes) and holds or
314/// flys through, expecting to continue on the next flight phase.
315/// - `with_collision_avoidance`: Whether to perform collision avoidance. It's
316/// not needed for docking or initial ascent because other airships must give
317/// way to this airship.
318/// - `radar_interval`: The interval at which to check on the positions and
319/// movements of other airships on the same route.
320///
321/// # Returns
322///
323/// An Action
324fn fly_airship_inner(
325 phase: AirshipFlightPhase,
326 wpos: Vec3<f32>,
327 goal_dist: f32,
328 initial_speed_factor: f32,
329 height_offset: f32,
330 with_terrain_following: bool,
331 direction_override: Option<Dir>,
332 flight_mode: FlightMode,
333 with_collision_avoidance: bool,
334 radar_interval: Duration,
335) -> impl Action<AirshipRouteContext> {
336 just(move |ctx, airship_context: &mut AirshipRouteContext| {
337 let remaining = airship_context
338 .avoidance_timer
339 .checked_sub(Duration::from_secs_f32(ctx.dt));
340 if remaining.is_none() {
341 airship_context.avoidance_timer = radar_interval;
342 // The collision avoidance checks are not done every tick (no dt required), only
343 // every 2-4 seconds.
344 if with_collision_avoidance {
345 if let Some(approach_index) = airship_context.current_approach_index
346 && let Some(route_id) = ctx
347 .state
348 .data()
349 .airship_sim
350 .assigned_routes
351 .get(&ctx.npc_id)
352 && let Some(route) = ctx.world.civs().airships.routes.get(route_id)
353 && let Some(approach) = route.approaches.get(approach_index)
354 && let Some(pilots) = ctx.state.data().airship_sim.route_pilots.get(route_id)
355 {
356 let mypos = ctx.npc.wpos;
357
358 // The intermediate reference distance is either the approach initial point
359 // (when cruising) or final point (when on
360 // approach).
361 let tracker_ref_dist = match phase {
362 AirshipFlightPhase::Cruise => Some(
363 approach
364 .approach_initial_pos
365 .distance_squared(approach.airship_pos.xy()),
366 ),
367 AirshipFlightPhase::ApproachFinal => Some(
368 approach
369 .approach_final_pos
370 .distance_squared(approach.airship_pos.xy()),
371 ),
372 _ => None,
373 };
374
375 if phase == AirshipFlightPhase::Cruise {
376 let position_tracker =
377 airship_context.pos_trackers.entry(ctx.npc_id).or_default();
378 position_tracker.add_position(mypos);
379 // Check if the airship is stuck in one place.
380 if position_tracker.is_stuck(ctx, approach)
381 && let Some(backout_pos) = position_tracker.next_backout_pos(ctx)
382 {
383 airship_context.avoid_mode = AirshipAvoidanceMode::Stuck(backout_pos);
384 } else {
385 // If the mode was stuck, but the airship is no longer stuck, clear the
386 // mode.
387 if matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Stuck(..))
388 {
389 airship_context.avoid_mode = AirshipAvoidanceMode::None;
390 }
391 }
392 }
393
394 if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Stuck(..)) {
395 // Collect the avoidance modes for other airships on the route.
396 let avoidance: Vec<AirshipAvoidanceMode> = pilots
397 .iter()
398 .filter(|pilot_id| **pilot_id != ctx.npc_id)
399 .filter_map(|pilot_id| {
400 ctx.state.data().npcs.get(*pilot_id).and_then(|pilot| {
401 let pilot_wpos = pilot.wpos;
402 let other_tracker = airship_context
403 .trackers
404 .entry(*pilot_id)
405 .or_insert_with(|| ZoneDistanceTracker {
406 fixed_pos: approach.airship_pos.xy(),
407 stable_tolerance: 20.0,
408 ref_dist: tracker_ref_dist,
409 ..Default::default()
410 });
411 // the tracker determines if the other airship is moving towards
412 // or away from my docking position
413 // and if towards, whether it is inside of my position (ahead of
414 // me) and if ahead, whether it is
415 // inside the reference distance. If ahead of me but no inside
416 // the reference distance, I should slow down.
417 // If ahead of me and inside the reference distance, I should
418 // slow down or hold depending on which
419 // phase I'm in.
420 let (trend, zone) =
421 other_tracker.update(mypos.xy(), pilot_wpos.xy());
422 match trend {
423 Some(DistanceTrend::ApproachingDock) => {
424 // other airship is moving towards the (my) docking
425 // position
426 match zone {
427 Some(DistanceZone::InsideMyDistance) => {
428 // other airship is ahead, on the same route,
429 // but outside
430 // the reference distance (either the approach
431 // initial point or final point)
432 match phase {
433 // If I'm currently cruising, slow down if
434 // the other airship is
435 // within 2000 blocks
436 AirshipFlightPhase::Cruise => {
437 let dist2 = mypos
438 .xy()
439 .distance_squared(pilot_wpos.xy());
440 if dist2 < 2000.0f32.powi(2) {
441 Some(AirshipAvoidanceMode::SlowDown)
442 } else {
443 None
444 }
445 },
446 // If I'm currently on approach, stop and
447 // hold
448 AirshipFlightPhase::ApproachFinal => {
449 Some(AirshipAvoidanceMode::Hold(
450 mypos,
451 Dir::from_unnormalized(
452 (approach.approach_final_pos
453 - mypos.xy())
454 .with_z(0.0),
455 )
456 .unwrap_or_default(),
457 ))
458 },
459 // If I'm currently transitioning to above
460 // the dock, hold position
461 AirshipFlightPhase::Transition => {
462 Some(AirshipAvoidanceMode::Hold(
463 mypos,
464 approach.airship_direction,
465 ))
466 },
467 _ => None,
468 }
469 },
470 Some(DistanceZone::InsideReference) => {
471 // other airship is ahead, on the same route,
472 // and inside
473 // the reference distance (either the approach
474 // initial point or final point)
475 match phase {
476 // If I'm currently on approach, slow down,
477 // to eventually
478 // hold near the dock.
479 AirshipFlightPhase::ApproachFinal => {
480 Some(AirshipAvoidanceMode::SlowDown)
481 },
482 // else I'm cruising, the other airship is
483 // well ahead, and
484 // I might eventually have to hold nearer
485 // the dock.
486 // There is no reference distance if on
487 // final.
488 _ => None,
489 }
490 },
491 // else other airship is behind me, ignore
492 _ => None,
493 }
494 },
495 // other airship is at the dock (or desending to the dock)
496 Some(DistanceTrend::Docked) => {
497 // other airship is ahead, on the same route, near the
498 // dock.
499 match phase {
500 // If I'm currently on approach, slow down, to
501 // eventually probably hold near the dock.
502 AirshipFlightPhase::ApproachFinal => {
503 Some(AirshipAvoidanceMode::SlowDown)
504 },
505 // If I'm currently transitioning to above the dock,
506 // hold position
507 AirshipFlightPhase::Transition => {
508 Some(AirshipAvoidanceMode::Hold(
509 mypos,
510 approach.airship_direction,
511 ))
512 },
513 // otherwise continue until some other condition is
514 // met
515 _ => None,
516 }
517 },
518 // else other airship is moving away from my dock or there
519 // isn't enough data to determine the trend.
520 // Do nothing.
521 _ => None,
522 }
523 })
524 })
525 .collect();
526
527 if let Some((hold_pos, hold_dir)) = avoidance.iter().find_map(|mode| {
528 if let AirshipAvoidanceMode::Hold(hold_pos, hold_dir) = mode {
529 Some((hold_pos, hold_dir))
530 } else {
531 None
532 }
533 }) {
534 if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::Hold(..))
535 {
536 airship_context.did_hold = true;
537 airship_context.avoid_mode =
538 AirshipAvoidanceMode::Hold(*hold_pos, *hold_dir);
539 airship_context.hold_timer = ctx.rng.gen_range(4.0..7.0);
540 airship_context.hold_announced = false;
541 }
542 } else if avoidance
543 .iter()
544 .any(|mode| matches!(mode, AirshipAvoidanceMode::SlowDown))
545 {
546 if !matches!(airship_context.avoid_mode, AirshipAvoidanceMode::SlowDown)
547 {
548 airship_context.slow_count += 1;
549 airship_context.avoid_mode = AirshipAvoidanceMode::SlowDown;
550 airship_context.speed_factor = initial_speed_factor * 0.5;
551 }
552 } else {
553 airship_context.avoid_mode = AirshipAvoidanceMode::None;
554 airship_context.speed_factor = initial_speed_factor;
555 }
556 }
557 }
558 } else {
559 airship_context.avoid_mode = AirshipAvoidanceMode::None;
560 airship_context.speed_factor = initial_speed_factor;
561 }
562 } else {
563 airship_context.avoidance_timer = remaining.unwrap_or(radar_interval);
564 }
565
566 if let AirshipAvoidanceMode::Stuck(unstick_target) = airship_context.avoid_mode {
567 // Unstick the airship
568 ctx.controller.do_goto_with_height_and_dir(
569 unstick_target,
570 1.5,
571 None,
572 None,
573 FlightMode::Braking(BrakingMode::Normal),
574 );
575 } else if let AirshipAvoidanceMode::Hold(hold_pos, hold_dir) = airship_context.avoid_mode {
576 airship_context.hold_timer -= ctx.dt;
577 if airship_context.hold_timer <= 0.0 {
578 if !airship_context.hold_announced {
579 airship_context.hold_announced = true;
580 ctx.controller
581 .say(None, Content::localized("npc-speech-pilot-announce_hold"));
582 } else {
583 ctx.controller
584 .say(None, Content::localized("npc-speech-pilot-continue_hold"));
585 }
586 airship_context.hold_timer = ctx.rng.gen_range(10.0..20.0);
587 }
588 // Hold position (same idea as holding station at the dock except allow
589 // oscillations)
590 let hold_pos = if matches!(ctx.npc.mode, SimulationMode::Simulated) {
591 hold_pos
592 } else {
593 // Airship is loaded, add some randomness to the hold position
594 // so that the airship doesn't look like it's stuck in one place.
595 // This also keeps the propellers spinning slowly and somewhat randomly.
596 hold_pos
597 + (Vec3::new(0.7, 0.8, 0.9).map(|e| (e * ctx.time.0).sin())
598 * Vec3::new(5.0, 5.0, 10.0))
599 .map(|e| e as f32)
600 };
601
602 // Holding position
603 ctx.controller.do_goto_with_height_and_dir(
604 hold_pos,
605 0.25,
606 None,
607 Some(hold_dir),
608 FlightMode::Braking(BrakingMode::Normal),
609 );
610 } else {
611 // use terrain height offset if specified
612 let height_offset_opt = if with_terrain_following {
613 Some(height_offset)
614 } else {
615 None
616 };
617 // Move the airship
618 ctx.controller.do_goto_with_height_and_dir(
619 wpos,
620 airship_context.speed_factor,
621 height_offset_opt,
622 direction_override,
623 flight_mode,
624 );
625 }
626 })
627 .repeat()
628 .boxed()
629 .stop_if(move |ctx: &mut NpcCtx| {
630 if flight_mode == FlightMode::FlyThrough {
631 // we only care about the xy distance (just get close to the target position)
632 ctx.npc.wpos.xy().distance_squared(wpos.xy()) < goal_dist.powi(2)
633 } else {
634 // Braking mode means the PID controller will be controlling all three axes
635 ctx.npc.wpos.distance_squared(wpos) < goal_dist.powi(2)
636 }
637 })
638 .debug(move || {
639 format!(
640 "fly airship, phase:{:?}, tgt pos:({}, {}, {}), goal dist:{}, speed:{}, height:{}, \
641 terrain following:{}, FlightMode:{:?}, collision avoidance:{}, radar interval:{}",
642 phase,
643 wpos.x,
644 wpos.y,
645 wpos.z,
646 goal_dist,
647 initial_speed_factor,
648 height_offset,
649 with_terrain_following,
650 flight_mode,
651 with_collision_avoidance,
652 radar_interval.as_secs_f32()
653 )
654 })
655 .map(|_, _| ())
656}
657
658/// Get the target position for airship movement given the target position, the
659/// default height above terrain, and the height above terrain for the airship
660/// route cruise phase. This samples terrain points aound the target pos to get
661/// the maximum terrain altitude in a 200 block radius of the target position
662/// (only checking 4 cardinal directions). and returns the input approach_pos
663/// with z equal to the maximum terrain alt + height or the default_alt
664/// whichever is greater.
665fn approach_target_pos(
666 ctx: &mut NpcCtx,
667 approach_pos: Vec2<f32>,
668 default_alt: f32,
669 height: f32,
670) -> Vec3<f32> {
671 // sample 4 terrain altitudes around the final approach point and take the max.
672 let max_alt = CARDINALS
673 .iter()
674 .map(|rpos| {
675 ctx.world
676 .sim()
677 .get_surface_alt_approx(approach_pos.as_() + rpos * 200)
678 })
679 .max_by(|a, b| a.partial_cmp(b).unwrap_or(Ordering::Equal))
680 .unwrap_or(default_alt);
681 approach_pos.with_z(max_alt + height)
682}
683
684/// Calculates how to resume a route. Called when the server starts up.
685/// The airship will be at the approach final point, and this function
686/// just needs to figure out to which approach index that correlates.
687/// Returns the index of the approach to resume on.
688fn resume_route(airships: &Airships, route_id: &u32, ctx: &mut NpcCtx) -> usize {
689 if let Some(route) = airships.routes.get(route_id) {
690 route
691 .approaches
692 .iter()
693 .enumerate()
694 .min_by_key(|(_, approach)| {
695 approach
696 .approach_final_pos
697 .distance_squared(ctx.npc.wpos.xy()) as i32
698 })
699 .map(|(index, _)| index)
700 .unwrap_or(0)
701 } else {
702 0
703 }
704}
705
706/// The NPC is the airship captain. This action defines the flight loop for the
707/// airship. The captain NPC is autonomous and will fly the airship along the
708/// assigned route. The routes are established and assigned to the captain NPCs
709/// when the world is generated.
710pub fn pilot_airship<S: State>(ship: common::comp::ship::Body) -> impl Action<S> {
711 /*
712 Phases of the airship flight:
713 1. Docked at a docking position. Hovering in place. Mode 3d with direction vector.
714 2. Ascending staight up, to flight transition point. Mode 3d with direction vector.
715 3. Cruising to destination initial point. Mode 2d with variable height offset (from terrain).
716 4. Flying to final point. Mode 3d with direction vector.
717 5. Flying to docking transition point. Mode 3d with direction vector.
718 6. Descending and docking. Mode 3d with direction vector.
719
720 +
721 +...+
722 + <------------ Docking Position
723 |
724 Aligned with |
725 Docking Direction |
726 |
727 |
728 |
729 Turn Towards | <----------- Final Point
730 Docking Pos /-
731 Docked Position (start) /-
732 | Turn Towards /--
733 | Final Point /-
734 -> -------------------Cruise------------------------------ <-------------------- Initial Point
735 +
736 +...+
737 +
738
739 Algorithm for piloting the airship:
740
741 Phase State Parameters Completion Conditions Collision Avoidance
742 1 Docked 3D Position, Dir Docking Timeout No
743 2 Ascent 3D Position, Dir Altitude reached No
744 3 Cruise 2D Position, Height 2D Position reached Yes
745 4 Approach 3D Position, Height, Dir 2D Position reached Yes
746 5 Final 3D Position, Dir 3D Position reached Yes
747 6 Landing 3D Position, Dir 3D Position reached No
748 */
749
750 now(move |ctx, route_context: &mut AirshipRouteContext| {
751
752 // get the assigned route
753 if let Some(route_id) = ctx.state.data().airship_sim.assigned_routes.get(&ctx.npc_id)
754 && let Some(route) = ctx.world.civs().airships.routes.get(route_id) {
755
756 route_context.site_ids = Some([route.approaches[0].site_id, route.approaches[1].site_id]);
757
758 // pilot_airship action is called the first time, there will be no current approach index
759 // but the airship will be sitting at the approach final point.
760 let (current_approach_index, resuming) = if let Some(current_approach_index) = route_context.current_approach_index {
761 (current_approach_index, false)
762 } else {
763 let resume_approach_index = resume_route(&ctx.world.civs().airships, route_id, ctx);
764 route_context.current_approach_index = Some(resume_approach_index);
765 (resume_approach_index, true)
766 };
767
768 // when current_approach_index exists, it means we're repeating the flight loop
769 // if approach index is 0, then the airship is fly from site 0 to site 1, and vice versa
770
771 let ship_body = comp::Body::from(ship);
772 // the approach flips in the middle of this loop after waiting at the dock.
773 // The route_context.current_approach_index is used to determine the current approach at the
774 // top of the function but any changes to route_context are not seen until the next iteration.
775 // For this loop, these are the two approaches. We use #2 after docking is completed.
776
777 let approach1 = route.approaches[current_approach_index].clone();
778 let approach2 = route.approaches[(current_approach_index + 1) % 2].clone();
779
780 // Startup delay
781 // If the server has just started (resuming is true)
782 // then the airship is at the approach final point.
783 // It should hover there for a random time so that not all airships land at the same time.
784 let start_delay = if resuming {
785 ctx.rng.gen_range(1..5) as f64 * 10.0
786 } else {
787 0.02
788 };
789 just(move |ctx, _| {
790 ctx.controller
791 .do_goto_with_height_and_dir(
792 approach1.approach_final_pos.with_z(approach1.airship_pos.z + approach1.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
793 0.4, None,
794 Some(approach1.airship_direction),
795 FlightMode::Braking(BrakingMode::Precise),
796 );
797 })
798 .repeat()
799 .stop_if(timeout(start_delay))
800
801 // Regular Flight Loop
802 // Fly 3D to Docking Transition Point, full PID control
803
804 .then(
805 fly_airship(
806 AirshipFlightPhase::Transition,
807 approach1.airship_pos + Vec3::unit_z() * (approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA),
808 50.0,
809 0.4,
810 approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA,
811 true,
812 Some(approach1.airship_direction),
813 FlightMode::Braking(BrakingMode::Normal),
814 true,
815 Duration::from_secs_f32(2.0),
816 ))
817 // Descend and Dock
818 // Docking
819 // Stop in increments and settle at 150 blocks and then 50 blocks from the dock.
820 // This helps to ensure that the airship docks vertically and avoids collisions
821 // with other airships and the dock. The speed_factor is high to
822 // give a strong response to the PID controller for the first
823 // three docking phases. The speed_factor is reduced for the final docking phase
824 // to give the impression that the airship propellers are not rotating.
825 // Vary the timeout to get variation in the docking sequence.
826 .then(
827 // descend to 125 blocks above the dock
828 just(move |ctx, _| {
829 ctx.controller
830 .do_goto_with_height_and_dir(
831 approach1.airship_pos + Vec3::unit_z() * 125.0,
832 0.8, None,
833 Some(approach1.airship_direction),
834 FlightMode::Braking(BrakingMode::Normal),
835 );
836 })
837 .repeat()
838 .stop_if(timeout(ctx.rng.gen_range(10.0..14.0))))
839 .then(
840 // descend to 35 blocks above the dock
841 just(move |ctx, _| {
842 ctx.controller
843 .do_goto_with_height_and_dir(
844 approach1.airship_pos + Vec3::unit_z() * 35.0,
845 0.7, None,
846 Some(approach1.airship_direction),
847 FlightMode::Braking(BrakingMode::Normal),
848 );
849 })
850 .repeat()
851 .stop_if(timeout(ctx.rng.gen_range(7.0..9.5))))
852 .then(
853 // descend to docking position
854 just(move |ctx: &mut NpcCtx, _| {
855 ctx.controller
856 .do_goto_with_height_and_dir(
857 approach1.airship_pos + ship_body.mount_offset(),
858 0.7, None,
859 Some(approach1.airship_direction),
860 FlightMode::Braking(BrakingMode::Precise),
861 );
862 })
863 .repeat()
864 .stop_if(timeout(ctx.rng.gen_range(6.0..8.0))))
865 // Announce arrival
866 .then(just(|ctx: &mut NpcCtx, _| {
867 ctx.controller
868 .say(None, Content::localized("npc-speech-pilot-landed"));
869 }))
870
871 // Docked - Wait at Dock
872 .then(
873 now(move |ctx, route_context:&mut AirshipRouteContext| {
874 // The extra time to hold at the dock is a route_context variable.
875 // Adjust the extra time based on the airship's behavior during the previous
876 // flight. If the airship had to hold position, add 30 seconds to the dock time.
877 // If the airship did not hold position, subtract 45 seconds from the dock time
878 // because we want to reverse the extra penalty faster than building it up in case
879 // the airship transitions to simulation mode.
880 // If the airship had to slow down add 15 and if not subtract 20.
881 if route_context.did_hold {
882 route_context.extra_hold_dock_time += 30.0;
883 } else if route_context.extra_hold_dock_time > 45.0 {
884 route_context.extra_hold_dock_time -= 45.0;
885 } else {
886 route_context.extra_hold_dock_time = 0.0;
887 }
888 if route_context.slow_count > 0 {
889 route_context.extra_slowdown_dock_time += 15.0;
890 } else if route_context.extra_slowdown_dock_time > 20.0 {
891 route_context.extra_slowdown_dock_time -= 20.0;
892 } else {
893 route_context.extra_slowdown_dock_time = 0.0;
894 }
895
896 let docking_time = route_context.extra_hold_dock_time + route_context.extra_slowdown_dock_time + Airships::docking_duration();
897 #[cfg(debug_assertions)]
898 {
899 if let Some(site_ids) = route_context.site_ids
900 && let Some(approach_index) = route_context.current_approach_index {
901 let docked_site_id = site_ids[approach_index];
902 let docked_site_name = ctx.index.sites.get(docked_site_id).name().to_string();
903 debug!("{}, Docked at {}, did_hold:{}, slow_count:{}, extra_hold_dock_time:{}, extra_slowdown_dock_time:{}, docking_time:{}", format!("{:?}", ctx.npc_id), docked_site_name, route_context.did_hold, route_context.slow_count, route_context.extra_hold_dock_time, route_context.extra_slowdown_dock_time, docking_time);
904 }
905 }
906 route_context.did_hold = false;
907 route_context.slow_count = 0;
908
909 just(move |ctx, _| {
910 ctx.controller
911 .do_goto_with_height_and_dir(
912 approach1.airship_pos + ship_body.mount_offset(),
913 0.75, None,
914 Some(approach1.airship_direction),
915 FlightMode::Braking(BrakingMode::Precise),
916 );
917 })
918 .repeat()
919 .stop_if(timeout(ctx.rng.gen_range(10.0..16.0)))
920 // While waiting, every now and then announce where the airship is going next.
921 .then(
922 just(move |ctx, route_context:&mut AirshipRouteContext| {
923 // get the name of the site the airship is going to next.
924 // The route_context.current_approach_index has not been switched yet,
925 // so the index is the opposite of the current approach index.
926 if let Some(site_ids) = route_context.site_ids {
927 let next_site_id = site_ids[(route_context.current_approach_index.unwrap_or(0) + 1) % 2];
928 let next_site_name = ctx.index.sites.get(next_site_id).name().to_string();
929 ctx.controller.say(
930 None,
931 Content::localized_with_args("npc-speech-pilot-announce_next", [
932 (
933 "dir",
934 Direction::from_dir(approach2.approach_initial_pos - ctx.npc.wpos.xy()).localize_npc(),
935 ),
936 ("dst", Content::Plain(next_site_name.to_string())),
937 ]),
938 );
939 }
940 })
941 )
942 .repeat()
943 .stop_if(timeout(docking_time as f64))
944 })
945 ).then(
946 // rotate the approach to the next approach index. Note the approach2 is already known,
947 // this is just changing the approach index in the context data for the next loop.
948 just(move |ctx, route_context:&mut AirshipRouteContext| {
949 let from_index = route_context.current_approach_index.unwrap_or(0);
950 let next_approach_index = (from_index + 1) % 2;
951 route_context.current_approach_index = Some(next_approach_index);
952 if let Some(site_ids) = route_context.site_ids {
953 ctx.controller.say(
954 None,
955 Content::localized_with_args("npc-speech-pilot-takeoff", [
956 ("src", Content::Plain(ctx.index.sites.get(site_ids[from_index]).name().to_string())),
957 ("dst", Content::Plain(ctx.index.sites.get(site_ids[next_approach_index]).name().to_string())),
958 ]),
959 );
960 }
961 })
962 ).then(
963 // Ascend to Cruise Alt, full PID control
964 fly_airship(
965 AirshipFlightPhase::Ascent,
966 approach1.airship_pos + Vec3::unit_z() * Airships::takeoff_ascent_height(),
967 50.0,
968 0.2,
969 0.0,
970 false,
971 Some(Dir::from_unnormalized((approach2.approach_initial_pos - ctx.npc.wpos.xy()).with_z(0.0)).unwrap_or_default()),
972 FlightMode::Braking(BrakingMode::Normal),
973 false,
974 Duration::from_secs_f32(120.0),
975 )
976 ).then(
977 // Fly 2D to Destination Initial Point
978 fly_airship(
979 AirshipFlightPhase::Cruise,
980 approach_target_pos(ctx, approach2.approach_initial_pos, approach2.airship_pos.z + approach2.height, approach2.height),
981 250.0,
982 1.0,
983 approach2.height,
984 true,
985 None,
986 FlightMode::FlyThrough,
987 true,
988 Duration::from_secs_f32(4.0),
989 )
990 ).then(
991 // Fly 3D to Destination Final Point, z PID control
992 fly_airship(
993 AirshipFlightPhase::ApproachFinal,
994 approach_target_pos(ctx, approach2.approach_final_pos, approach2.airship_pos.z + approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA, approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
995 250.0,
996 0.5,
997 approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA,
998 true,
999 Some(approach2.airship_direction),
1000 FlightMode::FlyThrough,
1001 true,
1002 Duration::from_secs_f32(2.0),
1003 )
1004 ).map(|_, _| ()).boxed()
1005 } else {
1006 // There are no routes assigned.
1007 // This is unexpected and never happens in testing, just do nothing so the compiler doesn't complain.
1008 finish().map(|_, _| ()).boxed()
1009 }
1010 })
1011 .repeat()
1012 .with_state(AirshipRouteContext::default())
1013 .map(|_, _| ())
1014}
1015
1016#[cfg(test)]
1017mod tests {
1018 use super::{DistanceTrend, DistanceZone, ZoneDistanceTracker};
1019 use vek::{Vec2, Vec3};
1020
1021 #[test]
1022 fn transition_zone_other_approaching_test() {
1023 let dock_pos = Vec2::new(0.0, 0.0);
1024 let my_pos: Vec2<f32> = Vec2::new(0.0, -100.0);
1025 let mut other_pos = Vec2::new(0.0, -50.0);
1026 let mut tracker = ZoneDistanceTracker {
1027 fixed_pos: dock_pos,
1028 stable_tolerance: 20.0,
1029 ..Default::default()
1030 };
1031 for _ in 0..50 {
1032 other_pos.y += 1.0;
1033 let (trend, zone) = tracker.update(my_pos, other_pos);
1034 assert!(!(matches!(trend, Some(DistanceTrend::DepartingDock))));
1035 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1036 }
1037 }
1038
1039 #[test]
1040 fn transition_zone_other_docked_test() {
1041 let dock_pos = Vec3::new(1050.0, 8654.33, 874.2);
1042 let my_pos: Vec3<f32> = Vec3::new(1000.0, 8454.33, 574.2);
1043 let mut tracker = ZoneDistanceTracker {
1044 fixed_pos: dock_pos.xy(),
1045 stable_tolerance: 20.0,
1046 ..Default::default()
1047 };
1048 let time_0 = 27334.98f64;
1049 for i in 0..100 {
1050 let other_pos = dock_pos
1051 + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1052 * Vec3::new(5.0, 5.0, 10.0))
1053 .map(|e| e as f32)
1054 .xy();
1055 let (trend, zone) = tracker.update(my_pos.xy(), other_pos.xy());
1056 assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1057 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1058 }
1059 }
1060
1061 #[test]
1062 fn transition_zone_other_departing_test() {
1063 let dock_pos = Vec2::new(0.0, 0.0);
1064 let my_pos: Vec2<f32> = Vec2::new(-100.0, -100.0);
1065 let mut other_pos = Vec2::new(0.0, -1.0);
1066 let mut tracker = ZoneDistanceTracker {
1067 fixed_pos: dock_pos,
1068 stable_tolerance: 20.0,
1069 ..Default::default()
1070 };
1071 for _ in 0..50 {
1072 other_pos.y -= 1.0;
1073 let (trend, zone) = tracker.update(my_pos, other_pos);
1074 assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1075 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1076 }
1077 }
1078
1079 #[test]
1080 fn approach_other_approaching_behind_test() {
1081 let dock_pos = Vec2::new(10987.0, 5634.0);
1082 let afp_pos = Vec2::new(10642.0, 5518.5);
1083 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1084 let mut other_pos = Vec2::new(9965.0, 4501.8);
1085 let mut tracker = ZoneDistanceTracker {
1086 fixed_pos: dock_pos,
1087 stable_tolerance: 20.0,
1088 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1089 ..Default::default()
1090 };
1091 let step_y = (my_pos.y - other_pos.y) / 51.0;
1092 for _ in 0..50 {
1093 other_pos.y += step_y;
1094 let (trend, zone) = tracker.update(my_pos, other_pos);
1095 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1096 assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1097 }
1098 }
1099
1100 #[test]
1101 fn approach_other_approaching_in_zone2() {
1102 let dock_pos = Vec2::new(10987.0, 5634.0);
1103 let afp_pos = Vec2::new(10642.0, 5518.5);
1104 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1105 let mut other_pos = Vec2::new(9965.0, 5407.3);
1106 let mut tracker = ZoneDistanceTracker {
1107 fixed_pos: dock_pos,
1108 stable_tolerance: 20.0,
1109 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1110 ..Default::default()
1111 };
1112 let step_y = (afp_pos.y - other_pos.y) / 51.0;
1113 for _ in 0..50 {
1114 other_pos.y += step_y;
1115 let (trend, zone) = tracker.update(my_pos, other_pos);
1116 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1117 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1118 }
1119 }
1120
1121 #[test]
1122 fn approach_other_departing_in_zone2() {
1123 let dock_pos = Vec2::new(10987.0, 5634.0);
1124 let afp_pos = Vec2::new(10642.0, 5518.5);
1125 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1126 let mut other_pos = Vec2::new(9965.0, 5518.3);
1127 let mut tracker = ZoneDistanceTracker {
1128 fixed_pos: dock_pos,
1129 stable_tolerance: 20.0,
1130 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1131 ..Default::default()
1132 };
1133 let step_y = (my_pos.y - other_pos.y) / 51.0;
1134 for _ in 0..50 {
1135 other_pos.y += step_y;
1136 let (trend, zone) = tracker.update(my_pos, other_pos);
1137 assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1138 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1139 }
1140 }
1141
1142 #[test]
1143 fn approach_other_approaching_in_zone1() {
1144 let dock_pos = Vec2::new(10987.0, 5634.0);
1145 let afp_pos = Vec2::new(10642.0, 5518.5);
1146 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1147 let mut other_pos = Vec2::new(10655.0, 5518.7);
1148 let mut tracker = ZoneDistanceTracker {
1149 fixed_pos: dock_pos,
1150 stable_tolerance: 20.0,
1151 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1152 ..Default::default()
1153 };
1154 let step_x = (dock_pos.x - other_pos.x) / 50.0;
1155 let step_y = (dock_pos.y - other_pos.y) / 50.0;
1156 for _ in 0..50 {
1157 other_pos.x += step_x;
1158 other_pos.y += step_y;
1159 let (trend, zone) = tracker.update(my_pos, other_pos);
1160 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1161 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1162 }
1163 }
1164
1165 #[test]
1166 fn approach_other_docked() {
1167 let dock_pos = Vec2::new(10987.0, 5634.0);
1168 let afp_pos = Vec2::new(10642.0, 5518.5);
1169 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1170 let mut tracker = ZoneDistanceTracker {
1171 fixed_pos: dock_pos,
1172 stable_tolerance: 20.0,
1173 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1174 ..Default::default()
1175 };
1176 let time_0 = 354334.98f64;
1177 for i in 0..50 {
1178 let other_pos = dock_pos
1179 + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1180 * Vec3::new(5.0, 5.0, 10.0))
1181 .map(|e| e as f32)
1182 .xy();
1183 let (trend, zone) = tracker.update(my_pos, other_pos);
1184 assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1185 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1186 }
1187 }
1188
1189 #[test]
1190 fn approach_other_departing_in_zone1() {
1191 let dock_pos = Vec2::new(10987.0, 5634.0);
1192 let afp_pos = Vec2::new(10642.0, 5518.5);
1193 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1194 let mut other_pos = Vec2::new(10987.0, 5634.0);
1195 let mut tracker = ZoneDistanceTracker {
1196 fixed_pos: dock_pos,
1197 stable_tolerance: 20.0,
1198 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1199 ..Default::default()
1200 };
1201 let step_x = (afp_pos.x - dock_pos.x) / 51.0;
1202 let step_y = (afp_pos.y - dock_pos.y) / 51.0;
1203 for _ in 0..50 {
1204 other_pos.x += step_x;
1205 other_pos.y += step_y;
1206 let (trend, zone) = tracker.update(my_pos, other_pos);
1207 assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1208 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1209 }
1210 }
1211
1212 #[test]
1213 fn approach_other_departing_behind() {
1214 let dock_pos = Vec2::new(10987.0, 5634.0);
1215 let afp_pos = Vec2::new(10642.0, 5518.5);
1216 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1217 let mut other_pos = Vec2::new(9964.8, 5406.55);
1218 let mut tracker = ZoneDistanceTracker {
1219 fixed_pos: dock_pos,
1220 stable_tolerance: 20.0,
1221 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1222 ..Default::default()
1223 };
1224 let step_x = -11.37;
1225 let step_y = -23.87;
1226 for _ in 0..50 {
1227 other_pos.x += step_x;
1228 other_pos.y += step_y;
1229 let (trend, zone) = tracker.update(my_pos, other_pos);
1230 assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1231 assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1232 }
1233 }
1234}