diff --git a/addon-main.nas b/addon-main.nas
index b1c8f8d..7cae3da 100644
--- a/addon-main.nas
+++ b/addon-main.nas
@@ -23,7 +23,6 @@ var unload = func(addon) {
# e.g. myCanvas.del();
aerotow.uninit();
- thermal.uninit();
}
var main = func(addon) {
@@ -40,18 +39,29 @@ var main = func(addon) {
loadExtraNasalFiles(addon);
- aerotow.init();
- thermal.init();
+ aerotow.init(addon);
}
#
# Load extra Nasal files in main add-on directory
#
+# addon - Addob object
+#
var loadExtraNasalFiles = func (addon) {
- foreach (var scriptName; ["aerotow", "messages", "thermal"]) {
+ var modules = [
+ "nasal/aircraft",
+ "nasal/message",
+ "nasal/dialogs/route",
+ "nasal/dialogs/thermal",
+ "nasal/flight-plan",
+ "nasal/scenario",
+ "aerotow",
+ ];
+
+ foreach (var scriptName; modules) {
var fileName = addon.basePath ~ "/" ~ scriptName ~ ".nas";
- if (io.load_nasal(fileName, scriptName)) {
+ if (io.load_nasal(fileName, "aerotow")) {
print("Aerotown Add-on module \"", scriptName, "\" loaded OK");
}
}
diff --git a/addon-menubar-items.xml b/addon-menubar-items.xml
index 27c1c4c..620af61 100644
--- a/addon-menubar-items.xml
+++ b/addon-menubar-items.xml
@@ -72,7 +72,7 @@
+
dialog-close
diff --git a/gui/dialogs/route-aerotow.xml b/gui/dialogs/route-aerotow.xml
index 2bdde26..99dd13c 100644
--- a/gui/dialogs/route-aerotow.xml
+++ b/gui/dialogs/route-aerotow.xml
@@ -516,7 +516,9 @@
true
nasal
-
+
diff --git a/messages.nas b/messages.nas
deleted file mode 100644
index 1a58058..0000000
--- a/messages.nas
+++ /dev/null
@@ -1,54 +0,0 @@
-#
-# Aerotow Everywhere - Add-on for FlightGear
-#
-# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
-#
-# Copyright (C) 2022 Roman Ludwicki
-#
-# Aerotow Everywhere is an Open Source project and it is licensed
-# under the GNU Public License v3 (GPLv3)
-#
-
-#
-# Display given message as OK.
-#
-# message - The text message to display on the screen and read by speech synthesizer.
-#
-var displayOk = func (message) {
- display(message, "ok");
-}
-
-#
-# Display given message as an error.
-#
-# message - The text message to display on the screen and read by speech synthesizer.
-#
-var displayError = func (message) {
- display(message, "error");
-}
-
-#
-# Display given message.
-#
-# message - The text message to display on the screen and read by speech synthesizer.
-# type - The type of message. It can take values as "ok" or "error".
-#
-var display = func (message, type) {
- # Print to console
- print("Aerotow Everywhere add-on: " ~ message);
-
- # Read the message by speech synthesizer
- props.globals.getNode("/sim/sound/voices/ai-plane").setValue(message);
-
- # Display message on the screen
- var durationInSec = int(size(message) / 12) + 3;
- var window = screen.window.new(nil, -40, 10, durationInSec);
- window.bg = [0.0, 0.0, 0.0, 0.40];
-
- window.fg = type == "error"
- ? [1.0, 0.0, 0.0, 1]
- : [0.0, 1.0, 0.0, 1];
-
- window.align = "center";
- window.write(message);
-}
diff --git a/nasal/aircraft.nas b/nasal/aircraft.nas
new file mode 100644
index 0000000..3998f53
--- /dev/null
+++ b/nasal/aircraft.nas
@@ -0,0 +1,184 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+#
+# Parent object of Aircraft
+#
+var Aircraft = {
+ #
+ # Constants
+ #
+ DISTANCE_DETERMINANT: 1000, # meters
+
+ #
+ # Constructor
+ #
+ # vs - vertical speed in ft per DISTANCE_DETERMINANT m
+ # seed - take-off speed
+ # speedLimit - max speed
+ # rolling - factor for rolling
+ # minRwyLength - minimum runway length required, in meters
+ # name - full name of aircraft used in route dialog
+ # nameMenuCall - short name of aircraft for call a plane from menu
+ # modelPath - Path to the aircraft model
+ #
+ new: func (vs, speed, speedLimit, rolling, minRwyLength, name, nameMenuCall, modelPath) {
+ var obj = { parents: [Aircraft] };
+
+ obj.vs = vs;
+ obj.speed = speed;
+ obj.speedLimit = speedLimit;
+ obj.rolling = rolling;
+ obj.minRwyLength = minRwyLength;
+ obj.name = name;
+ obj.nameMenuCall = nameMenuCall;
+ obj.modelPath = modelPath;
+
+ return obj;
+ },
+
+ #
+ # Check that given name match to aircraft name
+ #
+ # name - Name of aircraft to check.
+ #
+ # Return 1 when match, otherwise 0.
+ #
+ isModelName: func (name) {
+ return name == me.name or name == me.nameMenuCall;
+ },
+
+ #
+ # Return how much the altitide increases for a given vertical speed and distance
+ #
+ # distance - distance in meters
+ #
+ getAltChange: func (distance) {
+ return me.vs * (distance / Aircraft.DISTANCE_DETERMINANT);
+ },
+
+ #
+ # Return selected Aircraft object
+ #
+ # addon - Addon object
+ # isRouteMode - Use 1 to get the plane for the "Aerotow Route" dialog,
+ # use 0 (default) for call the airplane for towing.
+ #
+ getSelected: func (addon, isRouteMode = 0) {
+ var name = Aircraft.getSelectedAircraftName(addon, isRouteMode);
+ foreach (var aircraft; g_Aircrafts) {
+ if (aircraft.isModelName(name)) {
+ return aircraft;
+ }
+ }
+
+ # Fist as default
+ return g_Aircrafts[0];
+ },
+
+ #
+ # Return name of selected aircraft. Possible values depend of isRouteMode: "Cub", "DR400", "c182".
+ #
+ #
+ # addon - Addon object
+ # isRouteMode - Use 1 to get the plane for the "Aerotow Route" dialog,
+ # use 0 (default) for call the airplane for towing.
+ #
+ getSelectedAircraftName: func (addon, isRouteMode = 0) {
+ if (isRouteMode) {
+ return getprop(addon.node.getPath() ~ "/addon-devel/route/ai-model") or g_Aircrafts[0].name;
+ }
+
+ return getprop(addon.node.getPath() ~ "/addon-devel/ai-model") or g_Aircrafts[0].nameMenuCall;
+ },
+};
+
+#
+# Cub
+# Cruise Speed 61 kt
+# Max Speed 106 kt
+# Approach speed 44-52 kt
+# Stall speed 33 kt
+#
+var AircraftCub = {
+ #
+ # Constructor
+ #
+ new: func () {
+ return {
+ parents: [Aircraft],
+ vs: 200,
+ speed: 55,
+ speedLimit: 60,
+ rolling: 1,
+ minRwyLength: 280,
+ name: "Piper J3 Cub",
+ nameMenuCall: "Cub",
+ modelPath: "Aircraft/Aerotow/Cub/Models/Cub-ai.xml",
+ };
+ },
+};
+
+#
+# Robin DR 400
+# Cruise Speed 134 kt
+# Max speeed 166 kt
+# Stall speed 51 kt
+# Rate of climb: 825 ft/min
+#
+var AircraftRobin = {
+ #
+ # Constructor
+ #
+ new: func () {
+ return {
+ parents: [Aircraft],
+ vs: 285,
+ speed: 70,
+ speedLimit: 75,
+ rolling: 2,
+ minRwyLength: 470,
+ name: "Robin DR400",
+ nameMenuCall: "DR400",
+ modelPath: "Aircraft/Aerotow/DR400/Models/dr400-ai.xml",
+ };
+ },
+};
+
+#
+# Cessna 182
+# Cruise Speed 145 kt
+# Max speeed 175 kt
+# Stall speed 50 kt
+# Best climb: 924 ft/min
+#
+var AircraftC182 = {
+ new: func () {
+ return {
+ parents: [Aircraft],
+ vs: 295,
+ speed: 75,
+ speedLimit: 80,
+ rolling: 2.2,
+ minRwyLength: 508,
+ name: "Cessna 182",
+ nameMenuCall: "c182",
+ modelPath: "Aircraft/Aerotow/c182/Models/c182-ai.xml",
+ };
+ },
+};
+
+# Create Aircraft objects
+var g_Aircrafts = [
+ AircraftCub.new(),
+ AircraftRobin.new(),
+ AircraftC182.new(),
+];
diff --git a/nasal/dialogs/route.nas b/nasal/dialogs/route.nas
new file mode 100644
index 0000000..0c1e5a2
--- /dev/null
+++ b/nasal/dialogs/route.nas
@@ -0,0 +1,88 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+#
+# Object for hande Route Dialog
+#
+var RouteDialog = {
+ #
+ # Constructor
+ #
+ # addon - Addon object
+ #
+ new: func (addon) {
+ var obj = { parents: [RouteDialog] };
+
+ obj.addon = addon;
+ obj.addonNodePath = addon.node.getPath();
+
+ obj.maxRouteWaypoints = 10;
+ obj.listeners = [];
+
+ # Set listener for aerotow combo box value in route dialog for recalculate altitude change
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/route/ai-model", func () {
+ obj.calculateAltChangeAndTotals();
+ }));
+
+ # Set listeners for distance fields for calculate altitude change
+ for (var i = 0; i < obj.maxRouteWaypoints; i = i + 1) {
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/route/wpt[" ~ i ~ "]/distance-m", func () {
+ obj.calculateAltChangeAndTotals();
+ }));
+ }
+
+ return obj;
+ },
+
+ #
+ # Destructor
+ #
+ del: func () {
+ foreach (var listener; me.listeners) {
+ removelistener(listener);
+ }
+ },
+
+ #
+ # Calculate total distance and altitude and put in to property tree
+ #
+ calculateAltChangeAndTotals: func () {
+ var totalDistance = 0.0;
+ var totalAlt = 0.0;
+ var isEnd = 0;
+
+ var isRouteMode = 1;
+ var aircraft = Aircraft.getSelected(me.addon, isRouteMode);
+
+ for (var i = 0; i < me.maxRouteWaypoints; i = i + 1) {
+ var distance = getprop(me.addonNodePath ~ "/addon-devel/route/wpt[" ~ i ~ "]/distance-m");
+ if (distance == nil) {
+ break;
+ }
+
+ var altChange = aircraft.getAltChange(distance);
+ setprop(me.addonNodePath ~ "/addon-devel/route/wpt[" ~ i ~ "]/alt-change-agl-ft", altChange);
+
+ if (!isEnd) {
+ if (distance > 0.0) {
+ totalDistance = totalDistance + distance;
+ totalAlt = totalAlt + altChange;
+ }
+ else {
+ isEnd = 1;
+ }
+ }
+ }
+
+ setprop(me.addonNodePath ~ "/addon-devel/route/total/distance", totalDistance);
+ setprop(me.addonNodePath ~ "/addon-devel/route/total/alt", totalAlt);
+ },
+};
diff --git a/nasal/dialogs/thermal.nas b/nasal/dialogs/thermal.nas
new file mode 100644
index 0000000..8f8e9fc
--- /dev/null
+++ b/nasal/dialogs/thermal.nas
@@ -0,0 +1,93 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+var Thermal = {
+ #
+ # Constructor
+ #
+ # addon - Addon object
+ # message - Message object
+ #
+ new: func (addon, message) {
+ var obj = { parents: [Thermal] };
+
+ obj.addon = addon;
+ obj.addonNodePath = addon.node.getPath();
+ obj.message = message;
+ obj.listeners = [];
+
+ # Listener for calculate distance from meters to nautical miles.
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/add-thermal/distance-m", func (node) {
+ setprop(obj.addonNodePath ~ "/addon-devel/add-thermal/distance-nm", node.getValue() * globals.M2NM);
+ }));
+
+ # Listener for calculate strength from ft/s to m/s.
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/add-thermal/strength-fps", func (node) {
+ setprop(obj.addonNodePath ~ "/addon-devel/add-thermal/strength-mps", node.getValue() * globals.FPS2KT * globals.KT2MPS);
+ }));
+
+ # Listener for calculate diameter from ft to m.
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/add-thermal/diameter-ft", func (node) {
+ setprop(obj.addonNodePath ~ "/addon-devel/add-thermal/diameter-m", node.getValue() * globals.FT2M);
+ }));
+
+ # Listener for calculate height from ft to m.
+ append(obj.listeners, setlistener(obj.addonNodePath ~ "/addon-devel/add-thermal/height-msl", func (node) {
+ setprop(obj.addonNodePath ~ "/addon-devel/add-thermal/height-msl-m", node.getValue() * globals.FT2M);
+ }));
+
+ return obj;
+ },
+
+ #
+ # Destructor
+ #
+ del: func () {
+ foreach (var listener; me.listeners) {
+ removelistener(listener);
+ }
+ },
+
+ #
+ # Add thermal 300 m before glider position.
+ #
+ # Return 1 on successful, otherwise 0.
+ #
+ add: func () {
+ var heading = getprop("/orientation/heading-deg") or 0;
+ var distance = getprop(me.addonNodePath ~ "/addon-devel/add-thermal/distance-m") or 300;
+
+ var position = geo.aircraft_position();
+ position.apply_course_distance(heading, distance);
+
+ # Get random layer from 1 to 4
+ var layer = int(rand() * 4) + 1;
+
+ var args = props.Node.new({
+ "type": "thermal",
+ "model": "Models/Weather/altocumulus_layer" ~ layer ~ ".xml",
+ "latitude": position.lat(),
+ "longitude": position.lon(),
+ "strength-fps": getprop(me.addonNodePath ~ "/addon-devel/add-thermal/strength-fps") or 16.0,
+ "diameter-ft": getprop(me.addonNodePath ~ "/addon-devel/add-thermal/diameter-ft") or 4000,
+ "height-msl": getprop(me.addonNodePath ~ "/addon-devel/add-thermal/height-msl") or 9000,
+ "search-order": "DATA_ONLY"
+ });
+
+ if (fgcommand("add-aiobject", args)) {
+ me.message.success("The thermal has been added");
+ return 1;
+ }
+
+ me.message.error("Adding thermal failed");
+ return 0;
+ },
+};
diff --git a/nasal/flight-plan.nas b/nasal/flight-plan.nas
new file mode 100644
index 0000000..79ab508
--- /dev/null
+++ b/nasal/flight-plan.nas
@@ -0,0 +1,423 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+#
+# Flight Plan object
+#
+var FlightPlan = {
+ #
+ # Constants
+ #
+ FILENAME_FLIGHTPLAN: "aerotown-addon-flightplan.xml",
+
+ #
+ # Constructor
+ #
+ # addon - Addon object
+ # message - Message object
+ # routeDialog - RouteDialog object
+ #
+ new: func (addon, message, routeDialog) {
+ var obj = { parents: [FlightPlan] };
+
+ obj.addon = addon;
+ obj.message = message;
+ obj.routeDialog = routeDialog;
+
+ obj.addonNodePath = addon.node.getPath();
+
+ obj.wptCount = 0;
+ obj.fpFileHandler = nil; # Handler for wrire flight plan to file
+ obj.coord = nil; # Coordinates for flight plan
+ obj.heading = nil; # AI plane heading
+ obj.altitude = nil; # AI plane altitude
+
+ obj.flightPlanPath = addon.storagePath ~ "/AI/FlightPlans/" ~ FlightPlan.FILENAME_FLIGHTPLAN;
+
+ return obj;
+ },
+
+ #
+ # Get airport an runway hash where the glider is located.
+ #
+ # Return hash with "airport" and "runway", otherwise nil.
+ #
+ getAirportAndRunway: func () {
+ var icao = getprop("/sim/airport/closest-airport-id");
+ if (icao == nil) {
+ me.message.error("Airport code cannot be obtained.");
+ return nil;
+ }
+
+ var runwayName = getprop("/sim/atc/runway");
+ if (runwayName == nil) {
+ me.message.error("Runway name cannot be obtained.");
+ return nil;
+ }
+
+ var airport = airportinfo(icao);
+
+ if (!contains(airport.runways, runwayName)) {
+ me.message.error("The " ~ icao ~" airport does not have runway " ~ runwayName);
+ return nil;
+ }
+
+ var runway = airport.runways[runwayName];
+
+ var minRwyLength = Aircraft.getSelected(me.addon).minRwyLength;
+ if (runway.length < minRwyLength) {
+ me.message.error(
+ "This runway is too short. Please choose a longer one than " ~ minRwyLength ~ " m "
+ ~ "(" ~ math.round(minRwyLength * globals.M2FT) ~ " ft)."
+ );
+ return nil;
+ }
+
+ return {
+ "airport": airport,
+ "runway": runway,
+ };
+ },
+
+ #
+ # Initialize flight plan and set it to property tree
+ #
+ # Return 1 on successful, otherwise 0.
+ #
+ initial: func () {
+ var location = me.getAirportAndRunway();
+ if (location == nil) {
+ return 0;
+ }
+
+ var aircraft = Aircraft.getSelected(me.addon);
+
+ me.initAircraftVariable(location.airport, location.runway, 0);
+
+ # inittial readonly waypoint
+ setprop(me.addonNodePath ~ "/addon-devel/route/init-wpt/heading-change", me.heading);
+ setprop(me.addonNodePath ~ "/addon-devel/route/init-wpt/distance-m", 100);
+ setprop(me.addonNodePath ~ "/addon-devel/route/init-wpt/alt-change-agl-ft", aircraft.vs / 10);
+
+ # in air
+ var wptData = [
+ {"hdgChange": 0, "dist": 5000, "altChange": aircraft.vs * 5},
+ {"hdgChange": -90, "dist": 1000, "altChange": aircraft.vs},
+ {"hdgChange": -90, "dist": 1000, "altChange": aircraft.vs},
+ {"hdgChange": 0, "dist": 5000, "altChange": aircraft.vs * 5},
+ {"hdgChange": -90, "dist": 1500, "altChange": aircraft.vs * 1.5},
+ {"hdgChange": -90, "dist": 1000, "altChange": aircraft.vs},
+ {"hdgChange": 0, "dist": 5000, "altChange": aircraft.vs * 5},
+ {"hdgChange": 0, "dist": 0, "altChange": 0},
+ {"hdgChange": 0, "dist": 0, "altChange": 0},
+ {"hdgChange": 0, "dist": 0, "altChange": 0},
+ ];
+
+ # Default route
+ # ^ - airport with heading direction to north
+ # 1 - 1st waypoint
+ # 2 - 2nd waypoint, etc.
+ #
+ # 2 . . 1 7
+ # . . .
+ # . . .
+ # 3 . .
+ # . . .
+ # . . .
+ # . . .
+ # . . .
+ # . . .
+ # . . .
+ # . ^ 6
+ # . .
+ # . .
+ # 4 . . . . 5
+
+ var index = 0;
+ foreach (var wpt; wptData) {
+ setprop(me.addonNodePath ~ "/addon-devel/route/wpt[" ~ index ~ "]/heading-change", wpt.hdgChange);
+ setprop(me.addonNodePath ~ "/addon-devel/route/wpt[" ~ index ~ "]/distance-m", wpt.dist);
+ setprop(me.addonNodePath ~ "/addon-devel/route/wpt[" ~ index ~ "]/alt-change-agl-ft", wpt.altChange);
+
+ index = index + 1;
+ }
+
+ me.routeDialog.calculateAltChangeAndTotals();
+
+ return 1;
+ },
+
+ #
+ # Generate the XML file with the flight plane for our plane for AI scenario.
+ # The file will be stored to $Fobj.HOME/Export/aerotown-addon-flightplan.xml.
+ #
+ # Return 1 on successful, otherwise 0.
+ #
+ generateXml: func () {
+ me.wptCount = 0;
+
+ var location = me.getAirportAndRunway();
+ if (location == nil) {
+ return 0;
+ }
+
+ me.fpFileHandler = io.open(me.flightPlanPath, "w");
+ io.write(
+ me.fpFileHandler,
+ "\n\n" ~
+ "\n\n" ~
+ "\n" ~
+ " \n"
+ );
+
+ var aircraft = Aircraft.getSelected(me.addon);
+
+ me.initAircraftVariable(location.airport, location.runway, 1);
+
+ # Start at 2 o'clock from the glider...
+ # Inital ktas must be >= 1.0
+ me.addWptGround({"hdgChange": 60, "dist": 25}, {"altChange": 0, "ktas": 5});
+
+ # Reset coord and heading
+ me.initAircraftVariable(location.airport, location.runway, 0);
+
+ var gliderOffsetM = me.getGliderOffsetFromRunwayThreshold(location.runway);
+
+ # ... and line up with the runway
+ me.addWptGround({"hdgChange": 0, "dist": 30 + gliderOffsetM}, {"altChange": 0, "ktas": 2.5});
+
+ # Rolling
+ me.addWptGround({"hdgChange": 0, "dist": 10}, {"altChange": 0, "ktas": 5});
+ me.addWptGround({"hdgChange": 0, "dist": 20}, {"altChange": 0, "ktas": 5});
+ me.addWptGround({"hdgChange": 0, "dist": 20}, {"altChange": 0, "ktas": aircraft.speed / 6});
+ me.addWptGround({"hdgChange": 0, "dist": 10}, {"altChange": 0, "ktas": aircraft.speed / 5});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 4});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 3.5});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 3});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 2.5});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 2});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 1.75});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 1.5});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed / 1.25});
+ me.addWptGround({"hdgChange": 0, "dist": 10 * aircraft.rolling}, {"altChange": 0, "ktas": aircraft.speed});
+
+ # Takeof
+ me.addWptAir({"hdgChange": 0, "dist": 100 * aircraft.rolling}, {"elevationPlus": 3, "ktas": aircraft.speed * 1.05});
+ me.addWptAir({"hdgChange": 0, "dist": 100}, {"altChange": aircraft.vs / 10, "ktas": aircraft.speed * 1.025});
+
+ var speedInc = 1.0;
+ foreach (var wptNode; props.globals.getNode(me.addonNodePath ~ "/addon-devel/route").getChildren("wpt")) {
+ var dist = wptNode.getChild("distance-m").getValue();
+ if (dist <= 0.0) {
+ break;
+ }
+
+ var hdgChange = wptNode.getChild("heading-change").getValue();
+ var altChange = aircraft.getAltChange(dist);
+
+ speedInc = speedInc + ((dist / Aircraft.DISTANCE_DETERMINANT) * 0.025);
+ var ktas = aircraft.speed * speedInc;
+ if (ktas > aircraft.speedLimit) {
+ ktas = aircraft.speedLimit;
+ }
+
+ me.addWptAir({"hdgChange": hdgChange, "dist": dist}, {"altChange": altChange, "ktas": ktas});
+ }
+
+ me.addWptEnd();
+
+ io.write(
+ me.fpFileHandler,
+ " \n" ~
+ "\n\n"
+ );
+ io.close(me.fpFileHandler);
+
+ return 1;
+ },
+
+ #
+ # Initialize AI aircraft variable
+ #
+ # airport - Object from airportinfo().
+ # runway - Object of runway from which the glider start.
+ # isGliderPos - Pass 1 for set AI aircraft's coordinates as glider position, 0 set coordinates as runway threshold.
+ #
+ initAircraftVariable: func (airport, runway, isGliderPos = 1) {
+ var gliderCoord = geo.aircraft_position();
+
+ # Set coordinates as glider position or runway threshold
+ me.coord = isGliderPos
+ ? gliderCoord
+ : geo.Coord.new().set_latlon(runway.lat, runway.lon);
+
+ # Set airplane heading as runway heading
+ me.heading = runway.heading;
+
+ # Set AI airplane altitude as glider altitude (assumed it's on the ground).
+ # It is more accurate than airport.elevation.
+ me.altitude = gliderCoord.alt() * globals.M2FT;
+ },
+
+ #
+ # Get distance from glider to runway threshold e.g. in case that the user taxi from the runway threshold
+ #
+ # runway - Object of runway from which the glider start
+ # Return the distance in metres, of the glider's displacement from the runway threshold.
+ #
+ getGliderOffsetFromRunwayThreshold: func (runway) {
+ var gliderCoord = geo.aircraft_position();
+ var rwyThreshold = geo.Coord.new().set_latlon(runway.lat, runway.lon);
+
+ return rwyThreshold.distance_to(gliderCoord);
+ },
+
+ #
+ # Add new waypoint on ground
+ #
+ # coordOffset - Hash for calculate next coordinates (lat, lon), with following fields:
+ # {
+ # hdgChange - How the aircraft's heading supposed to change? 0 - keep the same heading.
+ # dis - Distance in meters to calculate next waypoint coordinates.
+ # }
+ # performance - Hash with following fields:
+ # {
+ # altChange - How the aircraft's altitude is supposed to change? 0 - keep the same altitude.
+ # ktas - True air speed of AI plane at the waypoint.
+ # }
+ #
+ addWptGround: func (coordOffset, performance) {
+ me.wrireWpt(nil, coordOffset, performance, "ground");
+ },
+
+ #
+ # Add new waypoint in air
+ #
+ addWptAir: func (coordOffset, performance) {
+ me.wrireWpt(nil, coordOffset, performance, "air");
+ },
+
+ #
+ # Add "WAIT" waypoint
+ #
+ # sec - Number of seconds for wait
+ #
+ addWptWait: func (sec) {
+ me.wrireWpt("WAIT", {}, {}, nil, sec);
+ },
+
+ #
+ # Add "END" waypoint
+ #
+ addWptEnd: func () {
+ me.wrireWpt("END", {}, {});
+ },
+
+ #
+ # Write waypoint to flight plan file
+ #
+ # name - The name of waypoint
+ # coordOffset.hdgChange - How the aircraft's heading supposed to change?
+ # coordOffset.dist - Distance in meters to calculate next waypoint coordinates
+ # performance.altChange - How the aircraft's altitude is supposed to change?
+ # performance.elevationPlus - Set aircraft altitude as current terrain elevation + given value in feets.
+ # It's best to use for the first point in the air to avoid the plane collapsing into
+ # the ground in a bumpy airport
+ # performance.ktas - True air speed of AI plane at the waypoint
+ # groundAir - Allowed value: "ground or "air". The "ground" means that AI plane is on the ground, "air" - in air
+ # sec - Number of seconds for "WAIT" waypoint
+ #
+ wrireWpt: func (
+ name,
+ coordOffset,
+ performance,
+ groundAir = nil,
+ sec = nil
+ ) {
+ var coord = nil;
+ if (contains(coordOffset, "hdgChange") and contains(coordOffset, "dist")) {
+ me.heading = me.heading + coordOffset.hdgChange;
+ if (me.heading < 0) {
+ me.heading = 360 + me.heading;
+ }
+
+ if (me.heading > 360) {
+ me.heading = me.heading - 360;
+ }
+
+ me.coord.apply_course_distance(me.heading, coordOffset.dist);
+ coord = me.coord;
+ }
+
+ var alt = nil;
+ if (coord != nil and contains(performance, "elevationPlus")) {
+ var elevation = geo.elevation(coord.lat(), coord.lon());
+ me.altitude = elevation == nil
+ ? me.altitude + performance.elevationPlus
+ : elevation * globals.M2FT + performance.elevationPlus;
+ alt = me.altitude;
+ }
+ else if (contains(performance, "altChange")) {
+ me.altitude = me.altitude + performance.altChange;
+ alt = me.altitude;
+ }
+
+ var ktas = contains(performance, "ktas") ? performance.ktas : nil;
+
+ name = name == nil ? me.wptCount : name;
+ var data = me.getWptString(name, coord, alt, ktas, groundAir, sec);
+
+ io.write(me.fpFileHandler, data);
+
+ me.wptCount = me.wptCount + 1;
+ },
+
+ #
+ # Get single waypoint data as a string.
+ #
+ # name - Name of waypoint. Special names are: "WAIT", "END".
+ # coord - The Coord object
+ # alt - Altitude AMSL of AI plane
+ # ktas - True air speed of AI plane
+ # groundAir - Allowe value: "ground or "air". The "ground" means that AI plane is on the ground, "air" - in air
+ # sec - Number of seconds for "WAIT" waypoint
+ #
+ getWptString: func (name, coord = nil, alt = nil, ktas = nil, groundAir = nil, sec = nil) {
+ var str = " \n"
+ ~ " " ~ name ~ "\n";
+
+ if (coord != nil) {
+ str = str ~ " " ~ coord.lat() ~ "\n";
+ str = str ~ " " ~ coord.lon() ~ "\n";
+ str = str ~ " \n";
+ }
+
+ if (alt != nil) {
+ # str = str ~ " " ~ alt ~ "\n";
+ str = str ~ " " ~ alt ~ "\n";
+ }
+
+ if (ktas != nil) {
+ str = str ~ " " ~ ktas ~ "\n";
+ }
+
+ if (groundAir != nil) {
+ var onGround = groundAir == "ground" ? "true" : "false";
+ str = str ~ " " ~ onGround ~ "\n";
+ }
+
+ if (sec != nil) {
+ str = str ~ " " ~ sec ~ "\n";
+ }
+
+ return str ~ " \n";
+ },
+};
diff --git a/nasal/message.nas b/nasal/message.nas
new file mode 100644
index 0000000..c2316e5
--- /dev/null
+++ b/nasal/message.nas
@@ -0,0 +1,66 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+#
+# Object for display messages
+#
+var Message = {
+ #
+ # Constructor
+ #
+ new: func () {
+ return { parents: [Message] };
+ },
+
+ #
+ # Display given message as OK.
+ #
+ # message - The text message to display on the screen and read by speech synthesizer.
+ #
+ success: func (message) {
+ me.display(message, "ok");
+ },
+
+ #
+ # Display given message as an error.
+ #
+ # message - The text message to display on the screen and read by speech synthesizer.
+ #
+ error: func (message) {
+ me.display(message, "error");
+ },
+
+ #
+ # Display given message.
+ #
+ # message - The text message to display on the screen and read by speech synthesizer.
+ # type - The type of message. It can take values as "ok" or "error".
+ #
+ display: func (message, type) {
+ # Print to console
+ print("Aerotow Everywhere add-on: " ~ message);
+
+ # Read the message by speech synthesizer
+ props.globals.getNode("/sim/sound/voices/ai-plane").setValue(message);
+
+ # Display message on the screen
+ var durationInSec = int(size(message) / 12) + 3;
+ var window = screen.window.new(nil, -40, 10, durationInSec);
+ window.bg = [0.0, 0.0, 0.0, 0.40];
+
+ window.fg = type == "error"
+ ? [1.0, 0.0, 0.0, 1]
+ : [0.0, 1.0, 0.0, 1];
+
+ window.align = "center";
+ window.write(message);
+ },
+};
diff --git a/nasal/scenario.nas b/nasal/scenario.nas
new file mode 100644
index 0000000..8ef8636
--- /dev/null
+++ b/nasal/scenario.nas
@@ -0,0 +1,180 @@
+#
+# Aerotow Everywhere - Add-on for FlightGear
+#
+# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
+#
+# Copyright (C) 2022 Roman Ludwicki
+#
+# Aerotow Everywhere is an Open Source project and it is licensed
+# under the GNU Public License v3 (GPLv3)
+#
+
+#
+# Scenario object
+#
+var Scenario = {
+ #
+ # Constants
+ #
+ SCENARIO_ID: "aerotow_addon",
+ SCENARIO_NAME: "Aerotow Add-on",
+ SCENARIO_DESC: "This scenario starts the towing plane at the airport where the pilot with the glider is located. Use Ctrl-o to hook the plane.",
+ FILENAME_SCENARIO: "aerotown-addon.xml",
+
+ #
+ # Constructor
+ #
+ # addon - Addon object
+ # message - Message object
+ #
+ new: func (addon, message) {
+ var obj = { parents: [Scenario] };
+
+ obj.addon = addon;
+ obj.message = message;
+
+ obj.addonNodePath = addon.node.getPath();
+
+ obj.listeners = [];
+ obj.routeDialog = RouteDialog.new(addon);
+ obj.flightPlan = FlightPlan.new(addon, message, obj.routeDialog);
+ obj.isScenarioLoaded = 0;
+ obj.scenarioPath = addon.storagePath ~ "/" ~ Scenario.FILENAME_SCENARIO;
+
+ obj.flightPlan.initial();
+
+ append(obj.listeners, setlistener("/sim/presets/longitude-deg", func () {
+ # User change airport/runway
+ obj.flightPlan.initial();
+ }));
+
+ return obj;
+ },
+
+ #
+ # Destructor
+ #
+ del: func () {
+ me.routeDialog.del();
+
+ foreach (var listener; me.listeners) {
+ removelistener(listener);
+ }
+ },
+
+ #
+ # Generate the XML file with the AI scenario.
+ # The file will be stored to $FG_HOME/Export/aerotown-addon.xml.
+ #
+ generateXml: func () {
+ if (!me.flightPlan.generateXml()) {
+ return 0;
+ }
+
+ var scenarioXml = {
+ "PropertyList": {
+ "scenario": {
+ "name": Scenario.SCENARIO_NAME,
+ "description": Scenario.SCENARIO_DESC,
+ "entry": {
+ "callsign": "FG-TOW",
+ "type": "aircraft",
+ "class": "aerotow-dragger",
+ "model": Aircraft.getSelected(me.addon).modelPath,
+ "flightplan": FlightPlan.FILENAME_FLIGHTPLAN,
+ "repeat": 1,
+ }
+ }
+ }
+ };
+
+ var node = props.Node.new(scenarioXml);
+ io.writexml(me.scenarioPath, node);
+
+ me.addScenarioToPropertyList();
+
+ return 1;
+ },
+
+ #
+ # Add our new scenario to the "/sim/ai/scenarios" property list
+ # so that FlightGear will be able to load it by "load-scenario" command.
+ #
+ addScenarioToPropertyList: func () {
+ if (!me.isAlreadyAdded()) {
+ var scenarioData = {
+ "name": Scenario.SCENARIO_NAME,
+ "id": Scenario.SCENARIO_ID,
+ "description": Scenario.SCENARIO_DESC,
+ "path": me.scenarioPath,
+ };
+
+ props.globals.getNode("/sim/ai/scenarios").addChild("scenario").setValues(scenarioData);
+ }
+ },
+
+ #
+ # Return 1 if scenario is already added to "/sim/ai/scenarios" property list, otherwise return 0.
+ #
+ isAlreadyAdded: func () {
+ foreach (var scenario; props.globals.getNode("/sim/ai/scenarios").getChildren("scenario")) {
+ var id = scenario.getChild("id");
+ if (id != nil and id.getValue() == Scenario.SCENARIO_ID) {
+ return 1;
+ }
+ }
+
+ return 0;
+ },
+
+ #
+ # Load scenario
+ #
+ # Return 1 on successful, otherwise 0.
+ #
+ load: func () {
+ var args = props.Node.new({ "name": Scenario.SCENARIO_ID });
+ if (fgcommand("load-scenario", args)) {
+ me.isScenarioLoaded = 1;
+ me.message.success("Let's fly!");
+
+ # Enable engine sound
+ setprop(me.addonNodePath ~ "/addon-devel/sound/enable", 1);
+ return 1;
+ }
+
+ me.message.error("Tow failed!");
+ return 0;
+ },
+
+ #
+ # Unload scenario
+ #
+ # withMessages - Set 1 to display messages.
+ #
+ # Return 1 on successful, otherwise 0.
+ #
+ unload: func (withMessages = 0) {
+ if (me.isScenarioLoaded) {
+ var args = props.Node.new({ "name": Scenario.SCENARIO_ID });
+ if (fgcommand("unload-scenario", args)) {
+ me.isScenarioLoaded = 0;
+
+ if (withMessages) {
+ me.message.success("Aerotown disabled");
+ }
+ return 1;
+ }
+
+ if (withMessages) {
+ me.message.error("Aerotown disable failed");
+ }
+ return 0;
+ }
+
+ if (withMessages) {
+ me.message.success("Aerotown already disabled");
+ }
+ return 1;
+ },
+};
diff --git a/thermal.nas b/thermal.nas
deleted file mode 100644
index 7cbd483..0000000
--- a/thermal.nas
+++ /dev/null
@@ -1,90 +0,0 @@
-#
-# Aerotow Everywhere - Add-on for FlightGear
-#
-# Written and developer by Roman Ludwicki (PlayeRom, SP-ROM)
-#
-# Copyright (C) 2022 Roman Ludwicki
-#
-# Aerotow Everywhere is an Open Source project and it is licensed
-# under the GNU Public License v3 (GPLv3)
-#
-
-#
-# Constants
-#
-var ADDON = addons.getAddon("org.flightgear.addons.Aerotow");
-var ADDON_NODE_PATH = ADDON.node.getPath();
-
-#
-# Variables
-#
-var g_thermalListeners = [];
-
-#
-# Initialize thermal module
-#
-var init = func () {
- # Listener for calculate distance from meters to nautical miles.
- append(g_thermalListeners, setlistener(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/distance-m", func (node) {
- setprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/distance-nm", node.getValue() * globals.M2NM);
- }));
-
- # Listener for calculate strength from ft/s to m/s.
- append(g_thermalListeners, setlistener(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/strength-fps", func (node) {
- setprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/strength-mps", node.getValue() * globals.FPS2KT * globals.KT2MPS);
- }));
-
- # Listener for calculate diameter from ft to m.
- append(g_thermalListeners, setlistener(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/diameter-ft", func (node) {
- setprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/diameter-m", node.getValue() * globals.FT2M);
- }));
-
- # Listener for calculate height from ft to m.
- append(g_thermalListeners, setlistener(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/height-msl", func (node) {
- setprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/height-msl-m", node.getValue() * globals.FT2M);
- }));
-}
-
-#
-# Uninitialize thermal module
-#
-var uninit = func () {
- foreach (var listener; g_thermalListeners) {
- removelistener(listener);
- }
-}
-
-#
-# Add thermal 300 m before glider position.
-#
-# Return 1 on successful, otherwise 0.
-#
-var add = func () {
- var heading = getprop("/orientation/heading-deg") or 0;
- var distance = getprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/distance-m") or 300;
-
- var position = geo.aircraft_position();
- position.apply_course_distance(heading, distance);
-
- # Get random layer from 1 to 4
- var layer = int(rand() * 4) + 1;
-
- var args = props.Node.new({
- "type": "thermal",
- "model": "Models/Weather/altocumulus_layer" ~ layer ~ ".xml",
- "latitude": position.lat(),
- "longitude": position.lon(),
- "strength-fps": getprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/strength-fps") or 16.0,
- "diameter-ft": getprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/diameter-ft") or 4000,
- "height-msl": getprop(ADDON_NODE_PATH ~ "/addon-devel/add-thermal/height-msl") or 9000,
- "search-order": "DATA_ONLY"
- });
-
- if (fgcommand("add-aiobject", args)) {
- messages.displayOk("The thermal has been added");
- return 1;
- }
-
- messages.displayError("Adding thermal failed");
- return 0;
-}