# Code to process XML-based tutorials. See $FG_ROOT/Docs/README.tutorials # --------------------------------------------------------------------------------------- var STEP_INTERVAL = 5; # time between tutorial steps var EXIT_INTERVAL = 1; # time between fulfillment of a step and the start of the next step var tutorial = nil; var steps = []; var loop_id = 0; var current_step = nil; var num_errors = nil; var num_step_runs = nil; var last_run_time = nil; var audio_dir = nil; var startTutorial = func { var name = getprop("/sim/tutorials/current-tutorial"); if (name == nil) { screen.log.write("No tutorial selected"); return; } tutorial = nil; foreach (var c; props.globals.getNode("/sim/tutorials").getChildren("tutorial")) { if (c.getNode("name").getValue() == name) { tutorial = c; break; } } if (tutorial == nil) { screen.log.write('Unable to find tutorial "' ~ name ~ '"'); return; } stopTutorial(); screen.log.write('Loading tutorial "' ~ name ~ '" ...'); current_step = 0; num_step_runs = 0; num_errors = 0; last_run_time = time_elapsed.getValue(); steps = tutorial.getChildren("step"); view.point.save(); init_nasal(); STEP_INTERVAL = delay(tutorial, STEP_INTERVAL); run_nasal(tutorial); set_models(tutorial.getNode("models")); var dir = tutorial.getNode("audio-dir"); if (dir != nil) { audio_dir = getprop("/sim/fg-root") ~ "/" ~ dir.getValue() ~ "/"; } else { audio_dir = ""; } var presets = tutorial.getChild("presets"); if (presets != nil) { props.copy(presets, props.globals.getNode("/sim/presets")); fgcommand("presets-commit", props.Node.new()); if (getprop("/sim/presets/on-ground")) { var eng = props.globals.getNode("/controls/engines"); if (eng != nil) { foreach (var c; eng.getChildren("engine")) { c.getNode("magnetos", 1).setIntValue(3); c.getNode("throttle", 1).setDoubleValue(0.5); } } } } var timeofday = tutorial.getChild("timeofday"); if (timeofday != nil) { fgcommand("timeofday", props.Node.new({ "timeofday" : timeofday.getValue() })); } var init = tutorial.getNode("init"); set_properties(init); set_view(init); run_nasal(init); is_running(1); # needs to be after "presets-commit" # Pick up any weather conditions/scenarios set setprop("/environment/rebuild-layers", getprop("/environment/rebuild-layers") + 1); settimer(func { step_tutorial(loop_id += 1) }, STEP_INTERVAL); } var stopTutorial = func { loop_id += 1; if (is_running()) { var end = tutorial.getNode("end"); set_properties(end); run_nasal(end); } set_marker(); is_running(0); } _setlistener("/sim/crashed", stopTutorial); # - Gets the current step node from the tutorial # - If this is the first time the step is entered, it displays the instruction message # - Otherwise, it # - Checks if the exit conditions have been met. If so, it increments the step counter. # - Checks for any error conditions, in which case it displays a message to the screen and # increments an error counter # - Otherwise display the instructions for the step. # var step_tutorial = func(id) { id == loop_id or return; var continue_after = func(i) { settimer(func { step_tutorial(id) }, i) } if (current_step >= size(steps)) { # end of the tutorial var end = tutorial.getNode("end"); say_message(end, "Tutorial finished."); say_message(nil, "Deviations: " ~ num_errors); set_view(end) or view.point.restore(); stopTutorial(); return; } var step = steps[current_step]; set_marker(step); set_targets(tutorial.getNode("targets")); if (num_step_runs == 0) { # first time we've encountered this step say_message(step, "Tutorial step " ~ current_step); set_view(step); set_properties(step); run_nasal(step); num_step_runs += 1; return continue_after(delay(step, STEP_INTERVAL)); } # check for error conditions in random order foreach (var error; shuffle(step.getChildren("error"))) { if (props.condition(error.getNode("condition"))) { num_errors += 1; run_nasal(error); say_message(error); return continue_after(delay(error, STEP_INTERVAL)); } } # check for exit condition var exit = step.getNode("exit"); if (exit != nil) { if (!props.condition(exit.getNode("condition"))) { return continue_after(delay(exit, STEP_INTERVAL)); } run_nasal(exit); set_view(exit); } # success! current_step += 1; num_step_runs = 0; return continue_after(delay(tutorial, EXIT_INTERVAL)); } var delay = func(node, default) { if (node != nil) { var d = node.getNode("interval"); if (d != nil) { return num(d.getValue()); } } return num(default); } # scan all blocks and set their to or # the value of a property that points to # # /foo/bar # woof # # var set_properties = func(node) { node != nil or return; foreach (var c; node.getChildren("set")) { var dest = c.getChild("property", 0); var src = c.getChild("property", 1); var val = c.getChild("value"); dest != nil or die(" without "); if (val != nil) { setprop(dest.getValue(), val.getValue()); } elsif (src != nil) { src = getprop(src.getValue()); src != nil or die(" doesn't refer to defined property"); setprop(dest.getValue(), src); } else { die(" without or "); } } } var set_targets = func(node) { node != nil or return; var time = time_elapsed.getValue(); var dest = props.globals.getNode("/sim/tutorials/targets", 1); var aircraft = geo.aircraft_position(); var hdg = heading.getValue() + slip.getValue(); foreach (var t; node.getChildren()) { var lon = t.getNode("longitude-deg"); var lat = t.getNode("latitude-deg"); if (lon == nil or lat == nil) { die("target coords undefined"); } var target = geo.Coord.new().set_lonlat(lon.getValue(), lat.getValue()); var dist = aircraft.distance_to(target); var course = aircraft.course_to(target); var angle = geo.normdeg(course - hdg); if (angle >= 180) { angle -= 360; } var d = dest.getChild(t.getName(), t.getIndex(), 1); d.getNode("heading-deg", 1).setDoubleValue(course); d.getNode("direction-deg", 1).setDoubleValue(angle); var distN = d.getNode("distance-m", 1); var lastdist = distN.getValue(); distN.setDoubleValue(dist); if (lastdist != nil) { var speed = (lastdist - dist) / (time - last_run_time) + 0.00001; # m/s d.getNode("eta-min", 1).setDoubleValue(dist / (speed * 60)); } } last_run_time = time; } var models = []; var set_models = func(node) { node != nil or return; var manager = props.globals.getNode("/models", 1); foreach (var src; node.getChildren("model")) { var i = 0; for (; 1; i += 1) { if (manager.getChild("model", i, 0) == nil) { break; } } var dest = manager.getChild("model", i, 1); props.copy(src, dest); dest.getNode("load", 1); # makes the modelmgr load the model dest.removeChildren("load"); append(models, dest); } } var remove_models = func { foreach (var m; models) { m.getParent().removeChild(m.getName(), m.getIndex()); } models = []; } var set_view = func(node = nil) { node != nil or return; var v = node.getChild("view"); if (v != nil) { view.point.move(v); return 1; } return 0; } var set_marker = func(node = nil) { if (node != nil) { var loc = node.getNode("marker"); if (loc != nil) { var s = loc.getNode("scale"); marker.setValues({ "x/value": loc.getNode("x-m", 1).getValue(), "y/value": loc.getNode("y-m", 1).getValue(), "z/value": loc.getNode("z-m", 1).getValue(), "scale/value": s != nil ? s.getValue() : 1, "arrow-enabled": 1, }); return; } } marker.getNode("arrow-enabled", 1).setBoolValue(0); } # Set and return running state. Disable/enable stop menu. # var is_running = func(which = nil) { var prop = "/sim/tutorials/running"; if (which != nil) { setprop(prop, which); gui.menuEnable("tutorial-stop", which); } return getprop(prop); } # Output the message and optional sound recording. # var lastmsgcount = 0; var say_message = func(node, default = nil) { var msg = default; var audio = nil; var is_error = 0; if (node != nil) { is_error = node.getName() == "error"; var m = node.getChildren("message"); if (size(m)) { msg = m[rand() * size(m)].getValue(); } var a = node.getChildren("audio"); if (size(a)) { audio = a[rand() * size(a)].getValue(); } } if (msg != last_message.getValue() or (is_error and lastmsgcount == 1)) { # Error messages are only displayed every 10 seconds (2 iterations) # Other messages are only displayed if they change if (audio != nil) { var prop = { path : audio_dir, file : audio }; fgcommand("play-audio-message", props.Node.new(prop)); screen.log.write(msg, 1, 1, 1); } else { setprop("/sim/messages/copilot", msg); } last_message.setValue(msg); lastmsgcount = 0; } else { lastmsgcount += 1; } } var shuffle = func(vec) { var s = size(vec); forindex (var i; vec) { var j = rand() * s; if (i != j) { var swap = vec[j]; vec[j] = vec[i]; vec[i] = swap; } } return vec; } var run_nasal = func(node) { node != nil or return; foreach (var n; node.getChildren("nasal")) { if (n.getNode("module") == nil) { n.getNode("module", 1).setValue("__tutorial"); } fgcommand("nasal", n); } } var say = func(what, who = "copilot", delay = 0) { settimer(func { setprop("/sim/messages/", who, what) }, delay); } # Set up namespace "__tutorial" for embedded Nasal. # var init_nasal = func { globals.__tutorial = { say : say, # just exporting tutorial.say as __turorial.say next : func(n = 1) { current_step += n; num_step_runs = 0 }, previous : func(n = 1) { current_step -= n; num_step_runs = 0; if (current_step < 0) { current_step = 0; } }, }; } var dialog = func { fgcommand("dialog-show", props.Node.new({ "dialog-name" : "marker-adjust" })); } ## # Tutorial loader for development purposes. # Usage: tutorial.load("Aircraft/bo105/Tutorials/foo.xml", 1) # Loads this file to tutorial slot #1 (/sim/tutorials/tutorial[1]) # var load = func(file, index = 0) { props.globals.getNode("/sim/tutorials", 1).removeChild("tutorial", index); fgcommand("loadxml", props.Node.new({ "filename": file, "targetnode": "/sim/tutorials/tutorial[" ~ index ~ "]/", })); } var marker = nil; var heading = nil; var slip = nil; var last_message = nil; var time_elapsed = nil; _setlistener("/sim/signals/nasal-dir-initialized", func { marker = props.globals.getNode("/sim/model/marker", 1); heading = props.globals.getNode("/orientation/heading-deg", 1); slip = props.globals.getNode("/orientation/side-slip-deg", 1); last_message = props.globals.getNode("/sim/tutorials/last-message", 1); time_elapsed = props.globals.getNode("/sim/time/elapsed-sec", 1); });