@fboes/aerofly-patterns
Version:
Landegerät - Create random custom missions for Aerofly FS 4.
298 lines (297 loc) • 13.8 kB
JavaScript
import { Vector } from "@fboes/geojson";
import { Units } from "../../data/Units.js";
import { AviationWeatherApi } from "../general/AviationWeatherApi.js";
import { Formatter } from "../general/Formatter.js";
import { AeroflyAircraftFinder } from "../../data/AeroflyAircraft.js";
import { Degree, degreeDifference, degreeToRad } from "../general/Degree.js";
import { Airports } from "../../data/Airports.js";
import { AeroflyMissionCheckpoint } from "@fboes/aerofly-custom-missions";
import { ScenarioWeather } from "../general/ScenarioWeather.js";
/**
* A scenario consists of the plane and its position relative to the airport,
* the weather,
* the active runway
* and the entry method.
*/
export class Scenario {
constructor(airport, configuration, date = null) {
this.airport = airport;
this.configuration = configuration;
if (!configuration.minimumSafeAltitude) {
configuration.minimumSafeAltitude = Airports[airport.id]?.minimumSafeAltitude ?? 0;
}
/**
* @type {number} in feet
*/
const minimumSafeAltitude = Math.max((this.airport.position.elevation ?? 0) + 1500, configuration.minimumSafeAltitude);
/**
* @type {ScenarioAircraft}
*/
this.aircraft = new ScenarioAircraft(airport, configuration.aircraft, configuration.initialDistance, minimumSafeAltitude, configuration.randomHeadingRange, configuration.livery);
this.weather = null;
this.date = date ?? new Date();
/**
* @type {AirportRunway?}
*/
this.activeRunway = null;
/**
* @type {number} in feet per second
*/
this.activeRunwayCrosswindComponent = 0;
this.patternWaypoints = [];
this.entryWaypoint = null;
}
static async init(airport, configuration, date) {
const self = new Scenario(airport, configuration, date);
const weather = await AviationWeatherApi.fetchMetar([self.airport.id], self.date);
if (!weather.length) {
throw new Error("No METAR information from API for " + self.airport.id);
}
self.weather = new ScenarioWeather(weather[0]);
self.getActiveRunway();
return self;
}
getActiveRunway() {
const counterWindDirection = this.weather?.windDirection ?? 0;
const difference = (alignment) => {
return Math.abs(degreeDifference(alignment, counterWindDirection));
};
let possibleRunways = this.airport.runways
.filter((r) => {
return r.runwayType === null || r.runwayType === this.aircraft.data.type;
})
.filter((r) => {
return (this.aircraft.data.runwayLanding === null ||
this.aircraft.data.runwayLanding === 0 ||
this.aircraft.data.runwayLanding <= r.dimension[0]);
});
if (!this.weather || this.weather?.windSpeed <= 5) {
const preferredRunways = possibleRunways.filter((r) => {
return r.isPreferred;
});
if (preferredRunways.length > 0) {
possibleRunways = preferredRunways;
}
}
this.activeRunway = possibleRunways.reduce((a, b) => {
return difference(a.alignment) < difference(b.alignment) ? a : b;
});
/**
* @type {number} in meters
*/
const exitDistance = this.configuration.patternDistance * Units.meterPerNauticalMile;
/**
* @type {number} in meters
*/
const downwindDistance = this.configuration.patternDistance * Units.meterPerNauticalMile;
/**
* @type {number} in meters
*/
const finalDistance = this.configuration.patternFinalDistance * Units.meterPerNauticalMile;
/**
* @type {number} in degree
*/
const patternOrientation = Degree(this.activeRunway.alignment + (this.activeRunway.isRightPattern ? 90 : 270));
/**
* @type {number} in meters MSL
*/
let patternAltitude = this.configuration.patternAltitude / Units.feetPerMeter;
if (!this.configuration.isPatternAltitudeMsl && this.airport.position.elevation) {
patternAltitude += this.airport.position.elevation;
}
/**
* @type {number} meters to sink per meter distance to have 3° glide slope
*/
const glideSlope = 319.8 / Units.feetPerMeter / Units.meterPerNauticalMile;
if (this.weather?.windDirection) {
const crosswindAngle = degreeDifference(this.activeRunway.alignment, this.weather.windDirection);
this.activeRunwayCrosswindComponent = Math.sin(degreeToRad(crosswindAngle)) * this.weather.windSpeed;
}
// Final
const activeRunwayFinal = this.activeRunway.position.getPointBy(new Vector(finalDistance, Degree(this.activeRunway.alignment + 180)));
const finalAltitude = (this.airport.position.elevation ?? 0) + finalDistance * glideSlope;
activeRunwayFinal.elevation = Math.min(finalAltitude, patternAltitude);
// Base
const activeRunwayBase = activeRunwayFinal.getPointBy(new Vector(downwindDistance, patternOrientation));
const baseAltitude = finalAltitude + downwindDistance * glideSlope;
activeRunwayBase.elevation = Math.min(baseAltitude, patternAltitude);
// Crosswind
const activeRunwayCrosswind = this.activeRunway.position.getPointBy(new Vector(this.activeRunway.dimension[0] / Units.feetPerMeter + exitDistance, this.activeRunway.alignment));
activeRunwayCrosswind.elevation = patternAltitude;
// Entry
const activeRunwayEntry = this.airport.position.getPointBy(new Vector(downwindDistance, patternOrientation));
activeRunwayEntry.elevation = patternAltitude;
this.patternWaypoints = [
{
id: this.activeRunway.id + "-CROSS",
position: activeRunwayCrosswind,
},
{
id: this.activeRunway.id + "-DOWN",
position: activeRunwayCrosswind.getPointBy(new Vector(downwindDistance, patternOrientation)),
},
{
id: this.activeRunway.id + "-ENTRY",
position: activeRunwayEntry,
},
{
id: this.activeRunway.id + "-BASE",
position: activeRunwayBase,
},
{
id: this.activeRunway.id + "-FINAL",
position: activeRunwayFinal,
},
];
this.entryWaypoint = {
id: this.activeRunway.id + "-VENTRY",
position: activeRunwayEntry.getPointBy(new Vector(0.5 * Units.meterPerNauticalMile, Degree(patternOrientation + (this.activeRunway.isRightPattern ? -45 : 45)))),
};
}
get tags() {
const tags = ["approach", "pattern", "practice"];
if (this.activeRunwayCrosswindComponent > 4.5) {
tags.push("crosswind");
}
if (this.weather?.windSpeed && this.weather.windSpeed >= 22) {
tags.push("windy");
}
if (this.weather?.visibility && this.weather.visibility <= 3) {
tags.push("low_visibility");
}
if (Formatter.getLocalDaytime(this.date, this.airport.nauticalTimezone) === "night") {
tags.push("night");
}
if (this.activeRunway?.ilsFrequency) {
tags.push("instruments");
}
if (this.activeRunway?.dimension[0] && this.activeRunway.dimension[0] <= 2000) {
tags.push("short_runway");
}
return tags;
}
get description() {
if (!this.activeRunway) {
return null;
}
//const distance = Formatter.getNumberString(this.aircraft.distanceFromAirport);
const vector = Formatter.getVector(this.aircraft.vectorFromAirport);
const towered = this.airport.hasTower ? "towered" : "untowered";
let weatherAdjectives = this.weather ? Formatter.getWeatherAdjectives(this.weather) : "";
if (weatherAdjectives) {
weatherAdjectives = `a ${weatherAdjectives} `;
}
let crossWind = "";
if (this.activeRunwayCrosswindComponent > 4.5) {
crossWind = ` / ${Math.ceil(this.activeRunwayCrosswindComponent)} kts crosswind component`;
}
const runway = `${this.activeRunway.id} (${Math.round(this.activeRunway.alignment - this.airport.magneticDeclination)}° / ${Math.round(this.activeRunway.dimension[0] / Units.feetPerMeter).toLocaleString("en")}m${crossWind})`;
const elevation = this.airport.position.elevation !== null
? ` (${Math.ceil(this.airport.position.elevation * Units.feetPerMeter).toLocaleString("en")}ft)`
: "";
let description = `It is ${weatherAdjectives}${Formatter.getLocalDaytime(this.date, this.airport.nauticalTimezone)}, and you are ${vector} of the ${towered} airport ${this.airport.name}${elevation}. `;
let wind = ``;
if (this.weather) {
if (this.weather.windSpeed < 1) {
wind = `As there is no wind`;
}
else if (this.weather.windSpeed <= 5) {
wind = `As there is almost no wind`;
}
else {
wind = `As the wind is ${this.weather.windSpeed ?? 0} kts from ${this.weather.windDirection ?? 0}°`;
}
}
description += wind ? `${wind}, the main landing runway is ${runway}. ` : `The main landing runway is ${runway}. `;
if (this.activeRunway.ilsFrequency && !this.aircraft.data.hasNoRadioNav) {
description += `You may want to use the ILS (${this.activeRunway.ilsFrequency.toFixed(2)}). `;
}
description += `Fly the ${this.activeRunway.isRightPattern ? "right-turn " : ""}pattern and land safely.`;
const airportDescription = this.airport.getDescription(this.aircraft.data.hasNoRadioNav !== true);
if (airportDescription) {
description += "\n" + airportDescription;
}
return description;
}
/**
* @returns {AeroflyMissionCheckpoint[]} `Waypoint, type, length, frequency`; will return an empty array if not all preconditions are met
*/
get waypoints() {
if (!this.activeRunway) {
return [];
}
/**
* @param {AeroflyPatternsWaypointable} waypoint
* @param {"origin"|"departure_runway"|"waypoint"|"destination_runway"|"destination"} type
* @param {number?} [length] optional in meters
* @param {number?} [frequency] optional in Hz
* @returns {AeroflyMissionCheckpoint}
*/
const makeCheckpoint = (waypoint, type, length = null, frequency = null) => {
const checkpoint = new AeroflyMissionCheckpoint(waypoint.id, type, waypoint.position.longitude, waypoint.position.latitude, {
altitude: waypoint.position.elevation ?? 0,
});
if (length) {
checkpoint.length = length;
}
if (frequency) {
checkpoint.frequency = frequency;
}
return checkpoint;
};
/**
* @type {AeroflyMissionCheckpoint[]}
*/
const waypoints = [
makeCheckpoint(this.airport, "origin"),
makeCheckpoint(this.activeRunway, "departure_runway", this.activeRunway.dimension[0] / Units.feetPerMeter, this.activeRunway.ilsFrequency * 1_000_000),
];
this.patternWaypoints.forEach((p) => {
waypoints.push(makeCheckpoint(p, "waypoint"));
});
waypoints.push(makeCheckpoint(this.activeRunway, "destination_runway", this.activeRunway.dimension[0] / Units.feetPerMeter, this.activeRunway.ilsFrequency * 1_000_000), makeCheckpoint(this.airport, "destination"));
return waypoints;
}
}
/**
* @type {AeroflyPatternsWaypointable}
*/
class ScenarioAircraft {
/**
*
* @param {Airport} airport
* @param {string} aircraftCode Aerofly Aircraft Code
* @param {number} distanceFromAirport in Nautical Miles
* @param {number} minimumSafeAltitude in ft
* @param {number} [randomHeadingRange] in degree
* @param {string} [aircraftLivery] Aerofly Aircraft Code
*/
constructor(airport, aircraftCode, distanceFromAirport, minimumSafeAltitude, randomHeadingRange = 0, aircraftLivery = "") {
/**
* @type {Vector} how the aircraft relates to the airport
*/
this.vectorFromAirport = new Vector(distanceFromAirport * Units.meterPerNauticalMile, Math.random() * 360);
this.position = airport.position.getPointBy(this.vectorFromAirport);
const altitude = this.vectorFromAirport.bearing > 180 // bearing - 180 = course
? Math.ceil((minimumSafeAltitude - 1500) / 2000) * 2000 + 1500 // 3500, 5500, ..
: Math.ceil((minimumSafeAltitude - 500) / 2000) * 2000 + 500; // 4500, 6500, ..
this.position.elevation = altitude / Units.feetPerMeter;
/**
* @type {number} Heading of aircraft. Can be randomized
*/
this.heading = Degree(this.vectorFromAirport.bearing + 180 + (randomHeadingRange ? (Math.random() * 2 - 1) * randomHeadingRange : 0));
this.id = "current";
/**
* @type {string}
*/
this.aeroflyCode = aircraftCode;
/**
* @type {string}
*/
this.aeroflyLiveryCode = aircraftLivery;
/**
* @type {AeroflyAircraft} additional aircraft information like name and technical properties
*/
this.data = AeroflyAircraftFinder.get(aircraftCode);
}
}