Phi: nicer scroll animation for METAR widget
[fg:fgdata.git] / Nasal / multiplayer.nas
1 # Multiplayer
2 # ===========
3 #
4 # 1) Display chat messages from other aircraft to
5 #    the screen using screen.nas
6 #
7 # 2) Display a complete history of chat via dialog.
8 #
9 # 3) Allow chat messages to be written by the user.
10
11 var lastmsg = {};
12 var ignore = {};
13 var msg_loop_id = 0;
14 var msg_timeout = 0;
15 var log_file = nil;
16 var log_listeners = [];
17
18 var check_messages = func(loop_id) {
19     if (loop_id != msg_loop_id) return;
20     foreach (var mp; values(model.callsign)) {
21         var msg = mp.node.getNode("sim/multiplay/chat", 1).getValue();
22         if (msg and msg != lastmsg[mp.callsign]) {
23             if (!contains(ignore, mp.callsign))
24                 echo_message(mp.callsign, msg);
25             lastmsg[mp.callsign] = msg;
26         }
27     }
28     settimer(func check_messages(loop_id), 1);
29 }
30
31 var echo_message = func(callsign, msg) {
32     msg = string.trim(string.replace(msg, "\n", " "));
33
34     # Only prefix with the callsign if the message doesn't already include it.
35     if (find(callsign, msg) < 0)
36         msg = callsign ~ ": " ~ msg;
37
38     setprop("/sim/messages/mp-plane", msg);
39
40     # Add the chat to the chat history.
41     if (var history = getprop("/sim/multiplay/chat-history"))
42         msg = history ~ "\n" ~ msg;
43
44     setprop("/sim/multiplay/chat-history", msg);
45 }
46
47 var timeout_handler = func()
48 {
49     var t = props.globals.getNode("/sim/time/elapsed-sec").getValue();
50     if (t >= msg_timeout)
51     {
52         msg_timeout = 0;
53         setprop("/sim/multiplay/chat", "");
54     }
55     else
56         settimer(timeout_handler, msg_timeout - t);
57 }
58
59 var chat_listener = func(n)
60 {
61     var msg = n.getValue();
62     if (msg)
63     {
64         # ensure we see our own messages.
65         echo_message(getprop("/sim/multiplay/callsign"), msg);
66
67         # set expiry time
68         if (msg_timeout == 0)
69             settimer(timeout_handler, 10); # need new timer
70         msg_timeout = 10 + props.globals.getNode("/sim/time/elapsed-sec").getValue();
71     }
72 }
73
74
75
76 # Message composition function, activated using the - key.
77 var prefix = "Chat Message:";
78 var input = "";
79 var kbdlistener = nil;
80
81 var compose_message = func(msg = "")
82 {
83   input = prefix ~ msg;
84   gui.popupTip(input, 1000000);
85
86   kbdlistener = setlistener("/devices/status/keyboard/event", func (event) {
87     var key = event.getNode("key");
88
89     # Only check the key when pressed.
90     if (!event.getNode("pressed").getValue())
91       return;
92
93     if (handle_key(key.getValue()))
94       key.setValue(-1);           # drop key event
95   });
96 }
97
98 var handle_key = func(key)
99 {
100   if (key == `\n` or key == `\r`)
101   {
102     # CR/LF -> send the message
103
104     # Trim off the prefix
105     input = substr(input, size(prefix));
106     # Send the message and switch off the listener.
107     setprop("/sim/multiplay/chat", input);
108     removelistener(kbdlistener);
109     gui.popdown();
110     return 1;
111   }
112   elsif (key == 8)
113   {
114     # backspace -> remove a character
115
116     if (size(input) > size(prefix))
117     {
118       input = substr(input, 0, size(input) - 1);
119       gui.popupTip(input, 1000000);
120     }
121
122     # Always handle key so excessive backspacing doesn't toggle the heading autopilot
123     return 1;
124   }
125   elsif (key == 27)
126   {
127     # escape -> cancel
128     removelistener(kbdlistener);
129     gui.popdown();
130     return 1;
131   }
132   elsif ((key > 31) and (key < 128))
133   {
134     # Normal character - add it to the input
135     input ~= chr(key);
136     gui.popupTip(input, 1000000);
137     return 1;
138   }
139   else
140   {
141     # Unknown character - pass through
142     return 0;
143   }
144 }
145
146
147
148 # multiplayer.dialog.show() -- displays pilot list dialog
149 #
150 var PILOTSDLG_RUNNING = 0;
151
152 var dialog = {
153     init: func(x = nil, y = nil) {
154         me.x = x;
155         me.y = y;
156         me.bg = [0, 0, 0, 0.3];    # background color
157         me.fg = [[0.9, 0.9, 0.2, 1], [1, 1, 1, 1], [1, 0.5, 0, 1]]; # alternative active & disabled color
158         me.unit = 1;
159         me.toggle_unit();          # set to imperial
160         #
161         # "private"
162         var font = { name: "FIXED_8x13" };
163         me.header = ["chat", " callsign"," code"," model", " brg", func dialog.dist_hdr, func dialog.alt_hdr ~ " ", "ignore" ~ " "];
164         me.columns = [
165             { type: "button", legend: "", halign: "right", callback: "multiplayer.compose_message", "pref-height": 14, "pref-width": 14},
166             { type: "text", property: "callsign",    format: " %s",    label: "-----------",    halign: "fill" },
167             { type: "text", property: "id-code",    format: " %s",    label: "-----",    halign: "fill" },
168             { type: "text", property: "model-short", format: "%s",     label: "--------------", halign: "fill" },
169             { type: "text", property: "bearing-to",  format: " %3.0f", label: "----",           halign: "right", font: font },
170             { type: "text", property: func dialog.dist_node, format:" %8.2f", label: "---------", halign: "right", font: font },
171             { type: "text", property: func dialog.alt_node,  format:" %7.0f", label: "---------", halign: "right", font: font },
172             { type: "checkbox", property: "controls/invisible", callback: "multiplayer.dialog.toggle_ignore",
173               argprop: "callsign", label: "---------", halign: "right", font: font },
174         ];
175         me.cs_warnings = {};
176         me.name = "who-is-online";
177         me.dialog = nil;
178         me.loopid = 0;
179
180         me.listeners=[];
181         append(me.listeners, setlistener("/sim/startup/xsize", func me._redraw_()));
182         append(me.listeners, setlistener("/sim/startup/ysize", func me._redraw_()));
183         append(me.listeners, setlistener("/sim/signals/reinit-gui", func me._redraw_()));
184         append(me.listeners, setlistener("/sim/signals/multiplayer-updated", func me._redraw_()));
185     },
186     create: func {
187         if (me.dialog != nil)
188             me.close();
189
190         me.dialog = gui.dialog[me.name] = gui.Widget.new();
191         me.dialog.set("name", me.name);
192         me.dialog.set("dialog-name", me.name);
193         if (me.x != nil)
194             me.dialog.set("x", me.x);
195         if (me.y != nil)
196             me.dialog.set("y", me.y);
197
198         me.dialog.set("layout", "vbox");
199         me.dialog.set("default-padding", 0);
200
201         me.dialog.setColor(me.bg[0], me.bg[1], me.bg[2], me.bg[3]);
202
203         var titlebar = me.dialog.addChild("group");
204         titlebar.set("layout", "hbox");
205
206         var w = titlebar.addChild("button");
207         w.node.setValues({ "pref-width": 16, "pref-height": 16, legend: me.unit_button, default: 0 });
208         w.setBinding("nasal", "multiplayer.dialog.toggle_unit(); multiplayer.dialog._redraw_()");
209
210         titlebar.addChild("empty").set("stretch", 1);
211         titlebar.addChild("text").set("label", "Pilots: ");
212
213         var p = titlebar.addChild("text");
214         p.node.setValues({ label: "---", live: 1, format: "%d", property: "ai/models/num-players" });
215         titlebar.addChild("empty").set("stretch", 1);
216
217         var w = titlebar.addChild("button");
218         w.node.setValues({ "pref-width": 16, "pref-height": 16, legend: "", default: 0 });
219         # "Esc" causes dialog-close
220         w.set("key", "Esc");
221         w.setBinding("nasal", "multiplayer.dialog.del()");
222
223         me.dialog.addChild("hrule");
224
225         var content = me.dialog.addChild("group");
226         content.set("layout", "table");
227         content.set("default-padding", 0);
228
229         var row = 0;
230         var col = 0;
231         foreach (var h; me.header) {
232             var w = content.addChild("text");
233             var l = typeof(h) == "func" ? h() : h;
234             w.node.setValues({ "label": l, "row": row, "col": col, halign: me.columns[col].halign });
235             w = content.addChild("hrule");
236             w.node.setValues({ "row": row + 1, "col": col });
237             col += 1;
238         }
239         row += 2;
240         var odd = 1;
241         foreach (var mp; model.list) {
242             var col = 0;
243             var color = mp.node.getNode("model-installed").getValue() ? me.fg[odd = !odd] : me.fg[2];
244             foreach (var column; me.columns) {
245                 var w = nil;
246         if (column.type == "button") {
247             w = content.addChild("button");
248             w.node.setValues(column);
249             w.setBinding("nasal", column.callback ~ "(\"" ~ mp.callsign ~ ", \");");
250                     w.node.setValues({ row: row, col: col});
251         } else {
252             var p = typeof(column.property) == "func" ? column.property() : column.property;
253             if (column.type == "text") {
254                 w = content.addChild("text");
255                 w.node.setValues(column);
256             } elsif (column.type == "checkbox") {
257                 w = content.addChild("checkbox");
258                 w.setBinding("nasal", column.callback ~ "(getprop(\"" ~ mp.root ~ "/" ~ column.argprop ~ "\"))");
259             }
260                     w.node.setValues({ row: row, col: col, live: 1, property: mp.root ~ "/" ~ p });
261         }
262                 w.setColor(color[0], color[1], color[2], color[3]);
263                 col += 1;
264             }
265             row += 1;
266         }
267         if (me.x != nil)
268             me.dialog.set("x", me.x);
269         if (me.y != nil)
270             me.dialog.set("y", me.y);
271         me.update(me.loopid += 1);
272         fgcommand("dialog-new", me.dialog.prop());
273         fgcommand("dialog-show", me.dialog.prop());
274     },
275     update: func(id) {
276         id == me.loopid or return;
277         var self = geo.aircraft_position();
278         foreach (var mp; model.list) {
279             var n = mp.node;
280             var x = n.getNode("position/global-x").getValue();
281             var y = n.getNode("position/global-y").getValue();
282             var z = n.getNode("position/global-z").getValue();
283             var ac = geo.Coord.new().set_xyz(x, y, z);
284             var distance = nil;
285             var idcode = "----";
286             idcode = me.IDCode(n.getNode("instrumentation/transponder/transmitted-id").getValue());
287
288             call(func distance = self.distance_to(ac), nil, var err = []);
289
290             if ((size(err))or(distance==nil)) {
291                 # Oops, have errors. Bogus position data (and distance==nil).
292                 if (me.cs_warnings[mp.callsign]!=1) {
293                     # report each callsign once only (avoid cluttering)
294                     me.cs_warnings[mp.callsign] = 1;
295                     print("Received invalid position data: " ~ debug._error(mp.callsign));
296                 }
297                 #    debug.printerror(err);
298                 #    debug.dump(self, ac, mp);
299                 #    debug.tree(mp.node);
300             }
301             else
302             {
303                 # Node with valid position data (and "distance!=nil").
304                 n.setValues({
305                     "model-short": n.getNode("model-installed").getValue() ? mp.model : "[" ~ mp.model ~ "]",
306                     "bearing-to": self.course_to(ac),
307                     "distance-to-km": distance / 1000.0,
308                     "distance-to-nm": distance * M2NM,
309                     "position/altitude-m": n.getNode("position/altitude-ft").getValue() * FT2M,
310                     "controls/invisible": contains(ignore, mp.callsign),
311                     "id-code": idcode
312                 });
313             }
314         }
315         if (PILOTSDLG_RUNNING)
316             settimer(func me.update(id), 1, 1);
317     },
318     _redraw_: func {
319         if (me.dialog != nil) {
320             me.close();
321             me.create();
322         }
323     },
324     toggle_unit: func {
325         me.unit = !me.unit;
326         if (me.unit) {
327             me.alt_node = "position/altitude-m";
328             me.alt_hdr = "alt-m";
329             me.dist_hdr = "dist-km";
330             me.dist_node = "distance-to-km";
331             me.unit_button = "IM";
332         } else {
333             me.alt_node = "position/altitude-ft";
334             me.dist_node = "distance-to-nm";
335             me.alt_hdr = "alt-ft";
336             me.dist_hdr = "dist-nm";
337             me.unit_button = "SI";
338         }
339     },
340     toggle_ignore: func (callsign) {
341         if (contains(ignore, callsign)) {
342             delete(ignore, callsign);
343         } else {
344             ignore[callsign] = 1;
345         }
346     },
347     close: func {
348         if (me.dialog != nil) {
349             me.x = me.dialog.prop().getNode("x").getValue();
350             me.y = me.dialog.prop().getNode("y").getValue();
351         }
352         fgcommand("dialog-close", me.dialog.prop());
353     },
354     del: func {
355         PILOTSDLG_RUNNING = 0;
356         me.close();
357         foreach (var l; me.listeners)
358             removelistener(l);
359         delete(gui.dialog, me.name);
360     },
361     show: func {
362         if (!PILOTSDLG_RUNNING) {
363             PILOTSDLG_RUNNING = 1;
364             me.init(-2, -2);
365             me.create();
366             me.update(me.loopid += 1);
367         }
368     },
369     toggle: func {
370         if (!PILOTSDLG_RUNNING)
371             me.show();
372         else
373             me.del();
374     },
375     IDCode: func(code){
376
377         var idcode= "----";
378
379         if (code != nil )
380             {
381             if (code < 0)
382                 {
383                 idcode = "----";
384                 }
385             else
386                 {
387                 idcode = sprintf("%04d", code);
388                 }
389             }
390
391         return idcode;
392         },
393 };
394
395
396
397 # Autonomous singleton class that monitors multiplayer aircraft,
398 # maintains data in various structures, and raises signal
399 # "/sim/signals/multiplayer-updated" whenever an aircraft
400 # joined or left. Available data containers are:
401 #
402 #   multiplayer.model.data:        hash, key := /ai/models/~ path
403 #   multiplayer.model.callsign     hash, key := callsign
404 #   multiplayer.model.list         vector, sorted alphabetically (ASCII, case insensitive)
405 #
406 # All of them contain hash entries of this form:
407 #
408 # {
409 #    callsign: "BiMaus",
410 #    path: "Aircraft/bo105/Models/bo105.xml",      # relative file path
411 #    root: "/ai/models/multiplayer[4]",            # root property
412 #    node: {...},        # root property as props.Node hash
413 #    model: "bo105",     # model name (extracted from path)
414 #    sort: "bimaus",     # callsign in lower case (for sorting)
415 # }
416 #
417 var model = {
418     init: func {
419         me.L = [];
420         append(me.L, setlistener("ai/models/model-added", func(n) {
421             # Defer update() to the next convenient time to allow the
422             # new MP entry to become fully initialized.
423             settimer(func me.update(n.getValue()), 0);
424         }));
425         append(me.L, setlistener("ai/models/model-removed", func(n) {
426             # Defer update() to the next convenient time to allow the
427             # old MP entry to become fully deactivated.
428             settimer(func me.update(n.getValue()), 0);
429         }));
430         me.update();
431     },
432     update: func(n = nil) {
433         var changedNode = props.globals.getNode( n, 1 );
434         if (n != nil and changedNode.getName() != "multiplayer")
435             return;
436
437         me.data = {};
438         me.callsign = {};
439
440         foreach (var n; props.globals.getNode("ai/models", 1).getChildren("multiplayer")) {
441             if ((var valid = n.getNode("valid")) == nil or (!valid.getValue()))
442                 continue;
443             if ((var callsign = n.getNode("callsign")) == nil or !(callsign = callsign.getValue()))
444                 continue;
445             if (!(callsign = string.trim(callsign)))
446                 continue;
447
448             var path = n.getNode("sim/model/path").getValue();
449             var model = split(".", split("/", path)[-1])[0];
450             model = me.remove_suffix(model, "-model");
451             model = me.remove_suffix(model, "-anim");
452
453             var root = n.getPath();
454             var data = { node: n, callsign: callsign, model: model, root: root,
455                     sort: string.lc(callsign) };
456
457             me.data[root] = data;
458             me.callsign[callsign] = data;
459         }
460
461         me.list = sort(values(me.data), func(a, b) cmp(a.sort, b.sort));
462
463         setprop("ai/models/num-players", size(me.list));
464         setprop("sim/signals/multiplayer-updated", 1);
465     },
466     remove_suffix: func(s, x) {
467         var len = size(x);
468         if (substr(s, -len) == x)
469             return substr(s, 0, size(s) - len);
470         return s;
471     },
472 };
473
474 var mp_mode_changed = func(n) {
475     var is_online = n.getBoolValue();
476     foreach (var menuitem;["mp-chat","mp-chat-menu","mp-list","mp-carrier"])
477     {
478         gui.menuEnable(menuitem, is_online);
479     }
480
481     if (is_online) {
482         if (getprop("/sim/multiplay/write-message-log") and (log_file == nil)) {
483             var t = props.globals.getNode("/sim/time/real");
484             if (t == nil)
485             {
486                 # not ready yet, delay...
487                 settimer(func mp_mode_changed(n), 0.1);
488             }
489             else
490             {
491                 t = t.getValues();
492                 var ac = getprop("/sim/aircraft");
493                 var cs = getprop("/sim/multiplay/callsign");
494                 var apt = airportinfo().id;
495                 var file = string.normpath(getprop("/sim/fg-home") ~ "/mp-message.log");
496
497                 log_file = io.open(file, "a");
498                 io.write(log_file, sprintf("\n=====  %s %04d/%02d/%02d\t%s\t%s\t%s\n",
499                     ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][t.weekday],
500                     t.year, t.month, t.day, apt, ac, cs));
501                 io.flush(log_file);
502
503                 append(log_listeners, setlistener("/sim/signals/exit", func io.write(log_file, "=====  EXIT\n") and io.close(log_file)));
504                 append(log_listeners, setlistener("/sim/messages/mp-plane", func(n) {
505                     io.write(log_file, sprintf("%02d:%02d  %s\n",
506                         getprop("/sim/time/real/hour"),
507                         getprop("/sim/time/real/minute"),
508                         n.getValue()));
509                     io.flush(log_file);
510                 }));
511             }
512         }
513         check_messages(msg_loop_id += 1);
514     }
515     else
516     {
517         # stop message loop
518         msg_loop_id += 1;
519         if (log_file != nil)
520         {
521             io.write(log_file, "=====  DISCONNECT\n");
522             io.flush(log_file);
523             io.close(log_file);
524             foreach (var l; log_listeners)
525                 removelistener(l);
526             log_listeners = [];
527             log_file = nil;
528         }
529     }
530 }
531
532 _setlistener("/sim/signals/nasal-dir-initialized", func {
533
534   model.init();
535
536   setlistener("/sim/multiplay/online", mp_mode_changed, 1, 1);
537
538   # Call-back to ensure we see our own messages.
539   setlistener("/sim/multiplay/chat", chat_listener);
540 });
541