I suspect that work around for the /ai/models/model-added listener problem (i.e. that the listener is called in the middle of the process of adding/removing a MP entry) isn't the right way to solve the problem. The attached patch instead defer processing of the added/removed MP entries in the multiplayer Nasal module until the next time the Nasal subsystem is executed. This should prevent the problematic execution of the Nasal listener callback in the middle of C++ MP code.
430 lines
14 KiB
Text
430 lines
14 KiB
Text
# 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) {
|
|
# Defer update() to the next convenient time to allow the
|
|
# new MP entry to become fully initialized.
|
|
settimer(func me.update(n.getValue()), 0);
|
|
}));
|
|
append(me.L, setlistener("ai/models/model-removed", func(n) {
|
|
# Defer update() to the next convenient time to allow the
|
|
# old MP entry to become fully deactivated.
|
|
settimer(func me.update(n.getValue()), 0);
|
|
}));
|
|
me.update();
|
|
},
|
|
update: func(n = nil) {
|
|
var changedNode = props.globals.getNode( n, 1 );
|
|
if (n != nil and changedNode.getName() != "multiplayer")
|
|
return;
|
|
|
|
me.data = {};
|
|
me.callsign = {};
|
|
me.available = [];
|
|
me.unavailable = [];
|
|
|
|
foreach (var n; props.globals.getNode("ai/models", 1).getChildren("multiplayer")) {
|
|
if (!n.getNode("valid").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());
|
|
|
|
|