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