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; -}