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 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>() -> 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 // the approach flips in the middle of this loop after waiting at the dock.
772 // The route_context.current_approach_index is used to determine the current approach at the
773 // top of the function but any changes to route_context are not seen until the next iteration.
774 // For this loop, these are the two approaches. We use #2 after docking is completed.
775
776 let approach1 = route.approaches[current_approach_index].clone();
777 let approach2 = route.approaches[(current_approach_index + 1) % 2].clone();
778
779 // Startup delay
780 // If the server has just started (resuming is true)
781 // then the airship is at the approach final point.
782 // It should hover there for a random time so that not all airships land at the same time.
783 let start_delay = if resuming {
784 ctx.rng.gen_range(1..5) as f64 * 10.0
785 } else {
786 0.02
787 };
788 just(move |ctx, _| {
789 ctx.controller
790 .do_goto_with_height_and_dir(
791 approach1.approach_final_pos.with_z(approach1.airship_pos.z + approach1.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA),
792 0.4, None,
793 Some(approach1.airship_direction),
794 FlightMode::Braking(BrakingMode::Precise),
795 );
796 })
797 .repeat()
798 .stop_if(timeout(start_delay))
799
800 // Regular Flight Loop
801 // Fly 3D to Docking Transition Point, full PID control
802
803 .then(
804 fly_airship(
805 AirshipFlightPhase::Transition,
806 approach1.airship_pos + Vec3::unit_z() * (approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA),
807 50.0,
808 0.4,
809 approach1.height - AIRSHIP_DOCK_TRANSITION_HEIGHT_DELTA,
810 true,
811 Some(approach1.airship_direction),
812 FlightMode::Braking(BrakingMode::Normal),
813 true,
814 Duration::from_secs_f32(2.0),
815 ))
816 // Descend and Dock
817 // Docking
818 // Stop in increments and settle at 150 blocks and then 50 blocks from the dock.
819 // This helps to ensure that the airship docks vertically and avoids collisions
820 // with other airships and the dock. The speed_factor is high to
821 // give a strong response to the PID controller for the first
822 // three docking phases. The speed_factor is reduced for the final docking phase
823 // to give the impression that the airship propellers are not rotating.
824 // Vary the timeout to get variation in the docking sequence.
825 .then(
826 // descend to 125 blocks above the dock
827 just(move |ctx, _| {
828 ctx.controller
829 .do_goto_with_height_and_dir(
830 approach1.airship_pos + Vec3::unit_z() * 125.0,
831 0.8, None,
832 Some(approach1.airship_direction),
833 FlightMode::Braking(BrakingMode::Normal),
834 );
835 })
836 .repeat()
837 .stop_if(timeout(ctx.rng.gen_range(10.0..14.0))))
838 .then(
839 // descend to 35 blocks above the dock
840 just(move |ctx, _| {
841 ctx.controller
842 .do_goto_with_height_and_dir(
843 approach1.airship_pos + Vec3::unit_z() * 35.0,
844 0.7, None,
845 Some(approach1.airship_direction),
846 FlightMode::Braking(BrakingMode::Normal),
847 );
848 })
849 .repeat()
850 .stop_if(timeout(ctx.rng.gen_range(7.0..9.5))))
851 .then(
852 // descend to docking position
853 just(move |ctx: &mut NpcCtx, _| {
854 ctx.controller
855 .do_goto_with_height_and_dir(
856 approach1.airship_pos,
857 0.7, None,
858 Some(approach1.airship_direction),
859 FlightMode::Braking(BrakingMode::Precise),
860 );
861 })
862 .repeat()
863 .stop_if(timeout(ctx.rng.gen_range(6.0..8.0))))
864 // Announce arrival
865 .then(just(|ctx: &mut NpcCtx, _| {
866 ctx.controller
867 .say(None, Content::localized("npc-speech-pilot-landed"));
868 }))
869
870 // Docked - Wait at Dock
871 .then(
872 now(move |ctx, route_context:&mut AirshipRouteContext| {
873 // The extra time to hold at the dock is a route_context variable.
874 // Adjust the extra time based on the airship's behavior during the previous
875 // flight. If the airship had to hold position, add 30 seconds to the dock time.
876 // If the airship did not hold position, subtract 45 seconds from the dock time
877 // because we want to reverse the extra penalty faster than building it up in case
878 // the airship transitions to simulation mode.
879 // If the airship had to slow down add 15 and if not subtract 20.
880 if route_context.did_hold {
881 route_context.extra_hold_dock_time += 30.0;
882 } else if route_context.extra_hold_dock_time > 45.0 {
883 route_context.extra_hold_dock_time -= 45.0;
884 } else {
885 route_context.extra_hold_dock_time = 0.0;
886 }
887 if route_context.slow_count > 0 {
888 route_context.extra_slowdown_dock_time += 15.0;
889 } else if route_context.extra_slowdown_dock_time > 20.0 {
890 route_context.extra_slowdown_dock_time -= 20.0;
891 } else {
892 route_context.extra_slowdown_dock_time = 0.0;
893 }
894
895 let docking_time = route_context.extra_hold_dock_time + route_context.extra_slowdown_dock_time + Airships::docking_duration();
896 #[cfg(debug_assertions)]
897 {
898 if let Some(site_ids) = route_context.site_ids
899 && let Some(approach_index) = route_context.current_approach_index {
900 let docked_site_id = site_ids[approach_index];
901 let docked_site_name = ctx.index.sites.get(docked_site_id).name().to_string();
902 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);
903 }
904 }
905 route_context.did_hold = false;
906 route_context.slow_count = 0;
907
908 just(move |ctx, _| {
909 ctx.controller
910 .do_goto_with_height_and_dir(
911 approach1.airship_pos,
912 0.75, None,
913 Some(approach1.airship_direction),
914 FlightMode::Braking(BrakingMode::Precise),
915 );
916 })
917 .repeat()
918 .stop_if(timeout(ctx.rng.gen_range(10.0..16.0)))
919 // While waiting, every now and then announce where the airship is going next.
920 .then(
921 just(move |ctx, route_context:&mut AirshipRouteContext| {
922 // get the name of the site the airship is going to next.
923 // The route_context.current_approach_index has not been switched yet,
924 // so the index is the opposite of the current approach index.
925 if let Some(site_ids) = route_context.site_ids {
926 let next_site_id = site_ids[(route_context.current_approach_index.unwrap_or(0) + 1) % 2];
927 let next_site_name = ctx.index.sites.get(next_site_id).name().to_string();
928 ctx.controller.say(
929 None,
930 Content::localized_with_args("npc-speech-pilot-announce_next", [
931 (
932 "dir",
933 Direction::from_dir(approach2.approach_initial_pos - ctx.npc.wpos.xy()).localize_npc(),
934 ),
935 ("dst", Content::Plain(next_site_name.to_string())),
936 ]),
937 );
938 }
939 })
940 )
941 .repeat()
942 .stop_if(timeout(docking_time as f64))
943 })
944 ).then(
945 // rotate the approach to the next approach index. Note the approach2 is already known,
946 // this is just changing the approach index in the context data for the next loop.
947 just(move |ctx, route_context:&mut AirshipRouteContext| {
948 let from_index = route_context.current_approach_index.unwrap_or(0);
949 let next_approach_index = (from_index + 1) % 2;
950 route_context.current_approach_index = Some(next_approach_index);
951 if let Some(site_ids) = route_context.site_ids {
952 ctx.controller.say(
953 None,
954 Content::localized_with_args("npc-speech-pilot-takeoff", [
955 ("src", Content::Plain(ctx.index.sites.get(site_ids[from_index]).name().to_string())),
956 ("dst", Content::Plain(ctx.index.sites.get(site_ids[next_approach_index]).name().to_string())),
957 ]),
958 );
959 }
960 })
961 ).then(
962 // Ascend to Cruise Alt, full PID control
963 fly_airship(
964 AirshipFlightPhase::Ascent,
965 approach1.airship_pos + Vec3::unit_z() * Airships::takeoff_ascent_height(),
966 50.0,
967 0.2,
968 0.0,
969 false,
970 Some(Dir::from_unnormalized((approach2.approach_initial_pos - ctx.npc.wpos.xy()).with_z(0.0)).unwrap_or_default()),
971 FlightMode::Braking(BrakingMode::Normal),
972 false,
973 Duration::from_secs_f32(120.0),
974 )
975 ).then(
976 // Fly 2D to Destination Initial Point
977 fly_airship(
978 AirshipFlightPhase::Cruise,
979 approach_target_pos(ctx, approach2.approach_initial_pos, approach2.airship_pos.z + approach2.height, approach2.height),
980 250.0,
981 1.0,
982 approach2.height,
983 true,
984 None,
985 FlightMode::FlyThrough,
986 true,
987 Duration::from_secs_f32(4.0),
988 )
989 ).then(
990 // Fly 3D to Destination Final Point, z PID control
991 fly_airship(
992 AirshipFlightPhase::ApproachFinal,
993 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),
994 250.0,
995 0.5,
996 approach2.height - AIRSHIP_APPROACH_FINAL_HEIGHT_DELTA,
997 true,
998 Some(approach2.airship_direction),
999 FlightMode::FlyThrough,
1000 true,
1001 Duration::from_secs_f32(2.0),
1002 )
1003 ).map(|_, _| ()).boxed()
1004 } else {
1005 // There are no routes assigned.
1006 // This is unexpected and never happens in testing, just do nothing so the compiler doesn't complain.
1007 finish().map(|_, _| ()).boxed()
1008 }
1009 })
1010 .repeat()
1011 .with_state(AirshipRouteContext::default())
1012 .map(|_, _| ())
1013}
1014
1015#[cfg(test)]
1016mod tests {
1017 use super::{DistanceTrend, DistanceZone, ZoneDistanceTracker};
1018 use vek::{Vec2, Vec3};
1019
1020 #[test]
1021 fn transition_zone_other_approaching_test() {
1022 let dock_pos = Vec2::new(0.0, 0.0);
1023 let my_pos: Vec2<f32> = Vec2::new(0.0, -100.0);
1024 let mut other_pos = Vec2::new(0.0, -50.0);
1025 let mut tracker = ZoneDistanceTracker {
1026 fixed_pos: dock_pos,
1027 stable_tolerance: 20.0,
1028 ..Default::default()
1029 };
1030 for _ in 0..50 {
1031 other_pos.y += 1.0;
1032 let (trend, zone) = tracker.update(my_pos, other_pos);
1033 assert!(!(matches!(trend, Some(DistanceTrend::DepartingDock))));
1034 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1035 }
1036 }
1037
1038 #[test]
1039 fn transition_zone_other_docked_test() {
1040 let dock_pos = Vec3::new(1050.0, 8654.33, 874.2);
1041 let my_pos: Vec3<f32> = Vec3::new(1000.0, 8454.33, 574.2);
1042 let mut tracker = ZoneDistanceTracker {
1043 fixed_pos: dock_pos.xy(),
1044 stable_tolerance: 20.0,
1045 ..Default::default()
1046 };
1047 let time_0 = 27334.98f64;
1048 for i in 0..100 {
1049 let other_pos = dock_pos
1050 + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1051 * Vec3::new(5.0, 5.0, 10.0))
1052 .map(|e| e as f32)
1053 .xy();
1054 let (trend, zone) = tracker.update(my_pos.xy(), other_pos.xy());
1055 assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1056 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1057 }
1058 }
1059
1060 #[test]
1061 fn transition_zone_other_departing_test() {
1062 let dock_pos = Vec2::new(0.0, 0.0);
1063 let my_pos: Vec2<f32> = Vec2::new(-100.0, -100.0);
1064 let mut other_pos = Vec2::new(0.0, -1.0);
1065 let mut tracker = ZoneDistanceTracker {
1066 fixed_pos: dock_pos,
1067 stable_tolerance: 20.0,
1068 ..Default::default()
1069 };
1070 for _ in 0..50 {
1071 other_pos.y -= 1.0;
1072 let (trend, zone) = tracker.update(my_pos, other_pos);
1073 assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1074 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1075 }
1076 }
1077
1078 #[test]
1079 fn approach_other_approaching_behind_test() {
1080 let dock_pos = Vec2::new(10987.0, 5634.0);
1081 let afp_pos = Vec2::new(10642.0, 5518.5);
1082 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1083 let mut other_pos = Vec2::new(9965.0, 4501.8);
1084 let mut tracker = ZoneDistanceTracker {
1085 fixed_pos: dock_pos,
1086 stable_tolerance: 20.0,
1087 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1088 ..Default::default()
1089 };
1090 let step_y = (my_pos.y - other_pos.y) / 51.0;
1091 for _ in 0..50 {
1092 other_pos.y += step_y;
1093 let (trend, zone) = tracker.update(my_pos, other_pos);
1094 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1095 assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1096 }
1097 }
1098
1099 #[test]
1100 fn approach_other_approaching_in_zone2() {
1101 let dock_pos = Vec2::new(10987.0, 5634.0);
1102 let afp_pos = Vec2::new(10642.0, 5518.5);
1103 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1104 let mut other_pos = Vec2::new(9965.0, 5407.3);
1105 let mut tracker = ZoneDistanceTracker {
1106 fixed_pos: dock_pos,
1107 stable_tolerance: 20.0,
1108 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1109 ..Default::default()
1110 };
1111 let step_y = (afp_pos.y - other_pos.y) / 51.0;
1112 for _ in 0..50 {
1113 other_pos.y += step_y;
1114 let (trend, zone) = tracker.update(my_pos, other_pos);
1115 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1116 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1117 }
1118 }
1119
1120 #[test]
1121 fn approach_other_departing_in_zone2() {
1122 let dock_pos = Vec2::new(10987.0, 5634.0);
1123 let afp_pos = Vec2::new(10642.0, 5518.5);
1124 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1125 let mut other_pos = Vec2::new(9965.0, 5518.3);
1126 let mut tracker = ZoneDistanceTracker {
1127 fixed_pos: dock_pos,
1128 stable_tolerance: 20.0,
1129 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1130 ..Default::default()
1131 };
1132 let step_y = (my_pos.y - other_pos.y) / 51.0;
1133 for _ in 0..50 {
1134 other_pos.y += step_y;
1135 let (trend, zone) = tracker.update(my_pos, other_pos);
1136 assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1137 assert!(matches!(zone, Some(DistanceZone::InsideMyDistance) | None));
1138 }
1139 }
1140
1141 #[test]
1142 fn approach_other_approaching_in_zone1() {
1143 let dock_pos = Vec2::new(10987.0, 5634.0);
1144 let afp_pos = Vec2::new(10642.0, 5518.5);
1145 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1146 let mut other_pos = Vec2::new(10655.0, 5518.7);
1147 let mut tracker = ZoneDistanceTracker {
1148 fixed_pos: dock_pos,
1149 stable_tolerance: 20.0,
1150 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1151 ..Default::default()
1152 };
1153 let step_x = (dock_pos.x - other_pos.x) / 50.0;
1154 let step_y = (dock_pos.y - other_pos.y) / 50.0;
1155 for _ in 0..50 {
1156 other_pos.x += step_x;
1157 other_pos.y += step_y;
1158 let (trend, zone) = tracker.update(my_pos, other_pos);
1159 assert!(matches!(trend, Some(DistanceTrend::ApproachingDock) | None));
1160 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1161 }
1162 }
1163
1164 #[test]
1165 fn approach_other_docked() {
1166 let dock_pos = Vec2::new(10987.0, 5634.0);
1167 let afp_pos = Vec2::new(10642.0, 5518.5);
1168 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1169 let mut tracker = ZoneDistanceTracker {
1170 fixed_pos: dock_pos,
1171 stable_tolerance: 20.0,
1172 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1173 ..Default::default()
1174 };
1175 let time_0 = 354334.98f64;
1176 for i in 0..50 {
1177 let other_pos = dock_pos
1178 + (Vec3::new(0.7, 0.8, 0.9).map(|e| e * (time_0 + i as f64 * 1.37).sin())
1179 * Vec3::new(5.0, 5.0, 10.0))
1180 .map(|e| e as f32)
1181 .xy();
1182 let (trend, zone) = tracker.update(my_pos, other_pos);
1183 assert!(matches!(trend, Some(DistanceTrend::Docked) | None));
1184 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1185 }
1186 }
1187
1188 #[test]
1189 fn approach_other_departing_in_zone1() {
1190 let dock_pos = Vec2::new(10987.0, 5634.0);
1191 let afp_pos = Vec2::new(10642.0, 5518.5);
1192 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1193 let mut other_pos = Vec2::new(10987.0, 5634.0);
1194 let mut tracker = ZoneDistanceTracker {
1195 fixed_pos: dock_pos,
1196 stable_tolerance: 20.0,
1197 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1198 ..Default::default()
1199 };
1200 let step_x = (afp_pos.x - dock_pos.x) / 51.0;
1201 let step_y = (afp_pos.y - dock_pos.y) / 51.0;
1202 for _ in 0..50 {
1203 other_pos.x += step_x;
1204 other_pos.y += step_y;
1205 let (trend, zone) = tracker.update(my_pos, other_pos);
1206 assert!(!(matches!(trend, Some(DistanceTrend::ApproachingDock))));
1207 assert!(matches!(zone, Some(DistanceZone::InsideReference) | None));
1208 }
1209 }
1210
1211 #[test]
1212 fn approach_other_departing_behind() {
1213 let dock_pos = Vec2::new(10987.0, 5634.0);
1214 let afp_pos = Vec2::new(10642.0, 5518.5);
1215 let my_pos: Vec2<f32> = Vec2::new(9965.12, 5407.23);
1216 let mut other_pos = Vec2::new(9964.8, 5406.55);
1217 let mut tracker = ZoneDistanceTracker {
1218 fixed_pos: dock_pos,
1219 stable_tolerance: 20.0,
1220 ref_dist: Some(afp_pos.distance_squared(dock_pos)),
1221 ..Default::default()
1222 };
1223 let step_x = -11.37;
1224 let step_y = -23.87;
1225 for _ in 0..50 {
1226 other_pos.x += step_x;
1227 other_pos.y += step_y;
1228 let (trend, zone) = tracker.update(my_pos, other_pos);
1229 assert!(matches!(trend, Some(DistanceTrend::DepartingDock) | None));
1230 assert!(matches!(zone, Some(DistanceZone::OutsideMyDistance) | None));
1231 }
1232 }
1233}