84fa01a6d8
Still deciding if this is needed or in the right place, but adding as an experiment, can be reverted if people don’t like it.
547 lines
18 KiB
Text
547 lines
18 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 lastmsg = {};
|
|
var ignore = {};
|
|
var msg_loop_id = 0;
|
|
var msg_timeout = 0;
|
|
var log_file = nil;
|
|
var log_listeners = [];
|
|
|
|
var check_messages = func(loop_id) {
|
|
if (loop_id != msg_loop_id) return;
|
|
foreach (var mp; values(model.callsign)) {
|
|
var msg = mp.node.getNode("sim/multiplay/chat", 1).getValue();
|
|
if (msg and msg != lastmsg[mp.callsign]) {
|
|
if (!contains(ignore, mp.callsign))
|
|
echo_message(mp.callsign, msg);
|
|
lastmsg[mp.callsign] = msg;
|
|
}
|
|
}
|
|
settimer(func check_messages(loop_id), 1);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
var timeout_handler = func()
|
|
{
|
|
var t = props.globals.getNode("/sim/time/elapsed-sec").getValue();
|
|
if (t >= msg_timeout)
|
|
{
|
|
msg_timeout = 0;
|
|
setprop("/sim/multiplay/chat", "");
|
|
}
|
|
else
|
|
settimer(timeout_handler, msg_timeout - t);
|
|
}
|
|
|
|
var chat_listener = func(n)
|
|
{
|
|
var msg = n.getValue();
|
|
if (msg)
|
|
{
|
|
# ensure we see our own messages.
|
|
echo_message(getprop("/sim/multiplay/callsign"), msg);
|
|
|
|
# set expiry time
|
|
if (msg_timeout == 0)
|
|
settimer(timeout_handler, 10); # need new timer
|
|
msg_timeout = 10 + props.globals.getNode("/sim/time/elapsed-sec").getValue();
|
|
}
|
|
}
|
|
|
|
|
|
|
|
# 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);
|
|
}
|
|
|
|
# Always handle key so excessive backspacing doesn't toggle the heading autopilot
|
|
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 = ["chat", " callsign"," code"," model", " brg", func dialog.dist_hdr, func dialog.alt_hdr ~ " ", "ignore" ~ " "];
|
|
me.columns = [
|
|
{ type: "button", legend: "", halign: "right", callback: "multiplayer.compose_message", "pref-height": 14, "pref-width": 14},
|
|
{ type: "text", property: "callsign", format: " %s", label: "-----------", halign: "fill" },
|
|
{ type: "text", property: "id-code", format: " %s", label: "-----", halign: "fill" },
|
|
{ type: "text", property: "model-short", format: "%s", label: "--------------", halign: "fill" },
|
|
{ type: "text", property: "bearing-to", format: " %3.0f", label: "----", halign: "right", font: font },
|
|
{ type: "text", property: func dialog.dist_node, format:" %8.2f", label: "---------", halign: "right", font: font },
|
|
{ type: "text", property: func dialog.alt_node, format:" %7.0f", label: "---------", halign: "right", font: font },
|
|
{ type: "checkbox", property: "controls/invisible", callback: "multiplayer.dialog.toggle_ignore",
|
|
argprop: "callsign", label: "---------", halign: "right", font: font },
|
|
];
|
|
me.cs_warnings = {};
|
|
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 });
|
|
# "Esc" causes dialog-close
|
|
w.set("key", "Esc");
|
|
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.node.getNode("model-installed").getValue() ? me.fg[odd = !odd] : me.fg[2];
|
|
foreach (var column; me.columns) {
|
|
var w = nil;
|
|
if (column.type == "button") {
|
|
w = content.addChild("button");
|
|
w.node.setValues(column);
|
|
w.setBinding("nasal", column.callback ~ "(\"" ~ mp.callsign ~ ", \");");
|
|
w.node.setValues({ row: row, col: col});
|
|
} else {
|
|
var p = typeof(column.property) == "func" ? column.property() : column.property;
|
|
if (column.type == "text") {
|
|
w = content.addChild("text");
|
|
w.node.setValues(column);
|
|
} elsif (column.type == "checkbox") {
|
|
w = content.addChild("checkbox");
|
|
w.setBinding("nasal", column.callback ~ "(getprop(\"" ~ mp.root ~ "/" ~ column.argprop ~ "\"))");
|
|
}
|
|
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;
|
|
}
|
|
if (me.x != nil)
|
|
me.dialog.set("x", me.x);
|
|
if (me.y != nil)
|
|
me.dialog.set("y", me.y);
|
|
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;
|
|
var idcode = "----";
|
|
idcode = me.IDCode(n.getNode("instrumentation/transponder/transmitted-id").getValue());
|
|
|
|
call(func distance = self.distance_to(ac), nil, var err = []);
|
|
|
|
if ((size(err))or(distance==nil)) {
|
|
# Oops, have errors. Bogus position data (and distance==nil).
|
|
if (me.cs_warnings[mp.callsign]!=1) {
|
|
# report each callsign once only (avoid cluttering)
|
|
me.cs_warnings[mp.callsign] = 1;
|
|
print("Received invalid position data: " ~ debug._error(mp.callsign));
|
|
}
|
|
# debug.printerror(err);
|
|
# debug.dump(self, ac, mp);
|
|
# debug.tree(mp.node);
|
|
}
|
|
else
|
|
{
|
|
# Node with valid position data (and "distance!=nil").
|
|
n.setValues({
|
|
"model-short": n.getNode("model-installed").getValue() ? 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,
|
|
"controls/invisible": contains(ignore, mp.callsign),
|
|
"id-code": idcode
|
|
});
|
|
}
|
|
}
|
|
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";
|
|
}
|
|
},
|
|
toggle_ignore: func (callsign) {
|
|
if (contains(ignore, callsign)) {
|
|
delete(ignore, callsign);
|
|
} else {
|
|
ignore[callsign] = 1;
|
|
}
|
|
},
|
|
close: func {
|
|
if (me.dialog != nil) {
|
|
me.x = me.dialog.prop().getNode("x").getValue();
|
|
me.y = me.dialog.prop().getNode("y").getValue();
|
|
}
|
|
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();
|
|
},
|
|
IDCode: func(code){
|
|
|
|
var idcode= "----";
|
|
|
|
if (code != nil )
|
|
{
|
|
if (code < 0)
|
|
{
|
|
idcode = "----";
|
|
}
|
|
else
|
|
{
|
|
idcode = sprintf("%04d", code);
|
|
}
|
|
}
|
|
|
|
return idcode;
|
|
},
|
|
};
|
|
|
|
|
|
|
|
# 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)
|
|
#
|
|
# 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)
|
|
# sort: "bimaus", # callsign in lower case (for sorting)
|
|
# }
|
|
#
|
|
var model = {
|
|
init: func {
|
|
me.L = [];
|
|
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 = {};
|
|
|
|
foreach (var n; props.globals.getNode("ai/models", 1).getChildren("multiplayer")) {
|
|
if ((var valid = n.getNode("valid")) == nil or (!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 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) };
|
|
|
|
me.data[root] = data;
|
|
me.callsign[callsign] = 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;
|
|
},
|
|
};
|
|
|
|
var mp_mode_changed = func(n) {
|
|
var is_online = n.getBoolValue();
|
|
foreach (var menuitem;["mp-chat","mp-chat-menu","mp-list","mp-carrier"])
|
|
{
|
|
gui.menuEnable(menuitem, is_online);
|
|
}
|
|
|
|
if (is_online) {
|
|
if (getprop("/sim/multiplay/write-message-log") and (log_file == nil)) {
|
|
var t = props.globals.getNode("/sim/time/real");
|
|
if (t == nil)
|
|
{
|
|
# not ready yet, delay...
|
|
settimer(func mp_mode_changed(n), 0.1);
|
|
}
|
|
else
|
|
{
|
|
t = t.getValues();
|
|
var ac = getprop("/sim/aircraft");
|
|
var cs = getprop("/sim/multiplay/callsign");
|
|
var apt = airportinfo().id;
|
|
var file = string.normpath(getprop("/sim/fg-home") ~ "/mp-message.log");
|
|
|
|
log_file = io.open(file, "a");
|
|
io.write(log_file, 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));
|
|
io.flush(log_file);
|
|
|
|
append(log_listeners, setlistener("/sim/signals/exit", func io.write(log_file, "===== EXIT\n") and io.close(log_file)));
|
|
append(log_listeners, setlistener("/sim/messages/mp-plane", func(n) {
|
|
io.write(log_file, sprintf("%02d:%02d %s\n",
|
|
getprop("/sim/time/real/hour"),
|
|
getprop("/sim/time/real/minute"),
|
|
n.getValue()));
|
|
io.flush(log_file);
|
|
}));
|
|
}
|
|
}
|
|
check_messages(msg_loop_id += 1);
|
|
}
|
|
else
|
|
{
|
|
# stop message loop
|
|
msg_loop_id += 1;
|
|
if (log_file != nil)
|
|
{
|
|
io.write(log_file, "===== DISCONNECT\n");
|
|
io.flush(log_file);
|
|
io.close(log_file);
|
|
foreach (var l; log_listeners)
|
|
removelistener(l);
|
|
log_listeners = [];
|
|
log_file = nil;
|
|
}
|
|
}
|
|
}
|
|
|
|
_setlistener("/sim/signals/nasal-dir-initialized", func {
|
|
|
|
model.init();
|
|
|
|
setlistener("/sim/multiplay/online", mp_mode_changed, 1, 1);
|
|
|
|
# Call-back to ensure we see our own messages.
|
|
setlistener("/sim/multiplay/chat", chat_listener);
|
|
|
|
|
|
if (getprop("/sim/presets/avoided-mp-runway")) {
|
|
_setlistener("/sim/sceneryloaded", func {
|
|
gui.popupTip("Multi-player enabled, start moved to runway hold short position.");
|
|
});
|
|
}
|
|
});
|