# Multiplayer # =========== # # 1) Display chat messages from other aircraft to # the screen using screen.nas # # 2) Display a complete history of chat via dialog. # # 3) Allow chat messages to be written by the user. var is_active = func getprop("/sim/multiplay/txport") or getprop("/sim/multiplay/rxport"); var lastmsg = {}; var check_messages = func { foreach (var mp; values(model.callsign)) { var msg = mp.node.getNode("sim/multiplay/chat", 1).getValue(); if (msg and msg != lastmsg[mp.callsign]) { echo_message(mp.callsign, msg); lastmsg[mp.callsign] = msg; } } settimer(check_messages, 3); } var echo_message = func(callsign, msg) { msg = string.trim(string.replace(msg, "\n", " ")); # Only prefix with the callsign if the message doesn't already include it. if (find(callsign, msg) < 0) msg = callsign ~ ": " ~ msg; setprop("/sim/messages/mp-plane", msg); # Add the chat to the chat history. if (var history = getprop("/sim/multiplay/chat-history")) msg = history ~ "\n" ~ msg; setprop("/sim/multiplay/chat-history", msg); } settimer(func { if (is_active()) { if (getprop("/sim/multiplay/write-message-log")) { var ac = getprop("/sim/aircraft"); var cs = getprop("/sim/multiplay/callsign"); var apt = airportinfo().id; var t = props.globals.getNode("/sim/time/real").getValues(); var file = string.normpath(getprop("/sim/fg-home") ~ "/mp-message.log"); var f = io.open(file, "a"); io.write(f, sprintf("\n===== %s %04d/%02d/%02d\t%s\t%s\t%s\n", ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][t.weekday], t.year, t.month, t.day, apt, ac, cs)); setlistener("/sim/signals/exit", func io.write(f, "=====\n") and io.close(f)); setlistener("/sim/messages/mp-plane", func(n) { io.write(f, sprintf("%02d:%02d %s\n", getprop("/sim/time/real/hour"), getprop("/sim/time/real/minute"), n.getValue())); io.flush(f); }); } check_messages(); } # Call-back to ensure we see our own messages. setlistener("/sim/multiplay/chat", func(n) { echo_message(getprop("/sim/multiplay/callsign"), n.getValue()); }); }, 1); # Message composition function, activated using the - key. var prefix = "Chat Message:"; var input = ""; var kbdlistener = nil; var compose_message = func(msg = "") { input = prefix ~ msg; gui.popupTip(input, 1000000); kbdlistener = setlistener("/devices/status/keyboard/event", func (event) { var key = event.getNode("key"); # Only check the key when pressed. if (!event.getNode("pressed").getValue()) return; if (handle_key(key.getValue())) key.setValue(-1); # drop key event }); } var handle_key = func(key) { if (key == `\n` or key == `\r`) { # CR/LF -> send the message # Trim off the prefix input = substr(input, size(prefix)); # Send the message and switch off the listener. setprop("/sim/multiplay/chat", input); removelistener(kbdlistener); gui.popdown(); return 1; } elsif (key == 8) { # backspace -> remove a character if (size(input) > size(prefix)) { input = substr(input, 0, size(input) - 1); gui.popupTip(input, 1000000); return 1; } } elsif (key == 27) { # escape -> cancel removelistener(kbdlistener); gui.popdown(); return 1; } elsif ((key > 31) and (key < 128)) { # Normal character - add it to the input input ~= chr(key); gui.popupTip(input, 1000000); return 1; } else { # Unknown character - pass through return 0; } } # multiplayer.dialog.show() -- displays pilot list dialog # var PILOTSDLG_RUNNING = 0; var dialog = { init: func(x = nil, y = nil) { me.x = x; me.y = y; me.bg = [0, 0, 0, 0.3]; # background color me.fg = [[0.9, 0.9, 0.2, 1], [1, 1, 1, 1], [1, 0.5, 0, 1]]; # alternative active & disabled color me.unit = 1; me.toggle_unit(); # set to imperial # # "private" var font = { name: "FIXED_8x13" }; me.header = [" callsign", "model", "brg", func dialog.dist_hdr, func dialog.alt_hdr ~ " "]; me.columns = [ { property: "callsign", format: " %s", label: "-----------", halign: "fill" }, { property: "model-short", format: "%s", label: "--------------", halign: "fill" }, { property: "bearing-to", format: " %3.0f", label: "----", halign: "right", font: font }, { property: func dialog.dist_node, format:" %8.2f", label: "---------", halign: "right", font: font }, { property: func dialog.alt_node, format:" %7.0f", label: "---------", halign: "right", font: font }, ]; me.name = "who-is-online"; me.dialog = nil; me.loopid = 0; me.listeners=[]; append(me.listeners, setlistener("/sim/startup/xsize", func me._redraw_())); append(me.listeners, setlistener("/sim/startup/ysize", func me._redraw_())); append(me.listeners, setlistener("/sim/signals/reinit-gui", func me._redraw_())); append(me.listeners, setlistener("/sim/signals/multiplayer-updated", func me._redraw_())); }, create: func { if (me.dialog != nil) me.close(); me.dialog = gui.dialog[me.name] = gui.Widget.new(); me.dialog.set("name", me.name); me.dialog.set("dialog-name", me.name); if (me.x != nil) me.dialog.set("x", me.x); if (me.y != nil) me.dialog.set("y", me.y); me.dialog.set("layout", "vbox"); me.dialog.set("default-padding", 0); me.dialog.setColor(me.bg[0], me.bg[1], me.bg[2], me.bg[3]); var titlebar = me.dialog.addChild("group"); titlebar.set("layout", "hbox"); var w = titlebar.addChild("button"); w.node.setValues({ "pref-width": 16, "pref-height": 16, legend: me.unit_button, default: 0 }); w.setBinding("nasal", "multiplayer.dialog.toggle_unit(); multiplayer.dialog._redraw_()"); titlebar.addChild("empty").set("stretch", 1); titlebar.addChild("text").set("label", "Pilots: "); var p = titlebar.addChild("text"); p.node.setValues({ label: "---", live: 1, format: "%d", property: "ai/models/num-players" }); titlebar.addChild("empty").set("stretch", 1); var w = titlebar.addChild("button"); w.node.setValues({ "pref-width": 16, "pref-height": 16, legend: "", default: 0 }); w.setBinding("nasal", "multiplayer.dialog.del()"); me.dialog.addChild("hrule"); var content = me.dialog.addChild("group"); content.set("layout", "table"); content.set("default-padding", 0); var row = 0; var col = 0; foreach (var h; me.header) { var w = content.addChild("text"); var l = typeof(h) == "func" ? h() : h; w.node.setValues({ "label": l, "row": row, "col": col, halign: me.columns[col].halign }); w = content.addChild("hrule"); w.node.setValues({ "row": row + 1, "col": col }); col += 1; } row += 2; var odd = 1; foreach (var mp; model.list) { var col = 0; var color = mp.available ? me.fg[odd = !odd] : me.fg[2]; foreach (var column; me.columns) { var w = content.addChild("text"); w.node.setValues(column); var p = typeof(column.property) == "func" ? column.property() : column.property; w.node.setValues({ row: row, col: col, live: 1, property: mp.root ~ "/" ~ p }); w.setColor(color[0], color[1], color[2], color[3]); col += 1; } row += 1; } me.update(me.loopid += 1); fgcommand("dialog-new", me.dialog.prop()); fgcommand("dialog-show", me.dialog.prop()); }, update: func(id) { id == me.loopid or return; var self = geo.aircraft_position(); foreach (var mp; model.list) { var n = mp.node; var x = n.getNode("position/global-x").getValue(); var y = n.getNode("position/global-y").getValue(); var z = n.getNode("position/global-z").getValue(); var ac = geo.Coord.new().set_xyz(x, y, z); var distance = nil; call(func distance = self.distance_to(ac), nil, var err = []); if (size(err)) { # debug.printerror(err); # debug.dump(self, ac, mp); # debug.tree(mp.node); } n.setValues({ "model-short": mp.available ? mp.model : "[" ~ mp.model ~ "]", "bearing-to": self.course_to(ac), "distance-to-km": distance / 1000.0, "distance-to-nm": distance * M2NM, "position/altitude-m": n.getNode("position/altitude-ft").getValue() * FT2M, }); } if (PILOTSDLG_RUNNING) settimer(func me.update(id), 1, 1); }, _redraw_: func { if (me.dialog != nil) { me.close(); me.create(); } }, toggle_unit: func { me.unit = !me.unit; if (me.unit) { me.alt_node = "position/altitude-m"; me.alt_hdr = "alt-m"; me.dist_hdr = "dist-km"; me.dist_node = "distance-to-km"; me.unit_button = "IM"; } else { me.alt_node = "position/altitude-ft"; me.dist_node = "distance-to-nm"; me.alt_hdr = "alt-ft"; me.dist_hdr = "dist-nm"; me.unit_button = "SI"; } }, close: func { fgcommand("dialog-close", me.dialog.prop()); }, del: func { PILOTSDLG_RUNNING = 0; me.close(); foreach (var l; me.listeners) removelistener(l); delete(gui.dialog, me.name); }, show: func { if (!PILOTSDLG_RUNNING) { PILOTSDLG_RUNNING = 1; me.init(-2, -2); me.create(); me.update(me.loopid += 1); } }, toggle: func { if (!PILOTSDLG_RUNNING) me.show(); else me.del(); }, }; # Autonomous singleton class that monitors multiplayer aircraft, # maintains data in various structures, and raises signal # "/sim/signals/multiplayer-updated" whenever an aircraft # joined or left. Available data containers are: # # multiplayer.model.data: hash, key := /ai/models/* path # multiplayer.model.callsign hash, key := callsign # multiplayer.model.list vector, sorted alphabetically (ASCII, case insensitive) # multiplayer.model.available unsorted list of players with available models # multiplayer.model.unavailable unsorted list of players with unavailable models # # All of them contain hash entries of this form: # # { # callsign: "BiMaus", # path: "Aircraft/bo105/Models/bo105.xml", # relative file path # root: "/ai/models/multiplayer[4]", # root property # node: {...}, # root property as props.Node hash # model: "bo105", # model name (extracted from path) # available: 2, # whether the model is installed (0: not inst, 1: AI, 2: regular) # sort: "bimaus", # callsign in lower case (for sorting) # } # var model = { init: func { me.L = []; me.warned = {}; me.fg_root = string.normpath(getprop("/sim/fg-root")) ~ '/'; append(me.L, setlistener("ai/models/model-added", func(n) me.update(n.getValue()))); append(me.L, setlistener("ai/models/model-removed", func(n) me.update(n.getValue()))); me.update(); }, update: func(n = nil) { var changedNode = props.globals.getNode( n, 1 ); if (n != nil and changedNode.getName() != "multiplayer") return; var changedNodeIndex = changedNode != nil ? changedNode.getIndex() : -1; me.data = {}; me.callsign = {}; me.available = []; me.unavailable = []; foreach (var n; props.globals.getNode("ai/models", 1).getChildren("multiplayer")) { # Ignore valid property for the newly added multiplayer aircraft. # It is false when model-added is triggered and will become true after this # listener is finished if (n.getIndex() != changedNodeIndex and !n.getNode("valid", 1).getValue()) continue; if ((var callsign = n.getNode("callsign")) == nil or !(callsign = callsign.getValue())) continue; if (!(callsign = string.trim(callsign))) continue; var path = n.getNode("sim/model/path").getValue(); var available = 0; if (io.stat(string.normpath(me.fg_root ~ "AI/" ~ path)) != nil) available = 1; elsif (io.stat(string.normpath(me.fg_root ~ path)) != nil) available = 2; elsif (!contains(me.warned, path)) me.warned[path] = print("MP model not installed: " ~ debug._error(path)); var model = split(".", split("/", path)[-1])[0]; model = me.remove_suffix(model, "-model"); model = me.remove_suffix(model, "-anim"); var root = n.getPath(); var data = { node: n, callsign: callsign, model: model, root: root, sort: string.lc(callsign), available: available }; me.data[root] = data; me.callsign[callsign] = data; append(available ? me.available : me.unavailable, data); } me.list = sort(values(me.data), func(a, b) cmp(a.sort, b.sort)); setprop("ai/models/num-players", size(me.list)); setprop("sim/signals/multiplayer-updated", 1); }, remove_suffix: func(s, x) { var len = size(x); if (substr(s, -len) == x) return substr(s, 0, size(s) - len); return s; }, }; _setlistener("sim/signals/nasal-dir-initialized", func model.init());