home · contact · privacy
82bfbed97fd8902e48aca9e86bb8ec3e15c109eb
[plomrogue2] / rogue_chat.html
1 <!DOCTYPE html>
2 <html><head>
3 <style>
4   pre {
5       display: inline-block;
6   }
7   pre a {
8       color: white;
9   }
10 </style>
11 </head><body>
12 <div>
13 terminal rows: <input id="n_rows" type="number" step=4 min=24 value=24 />
14 / terminal columns: <input id="n_cols" type="number" step=4 min=80 value=80 />
15 / <a href="https://plomlompom.com/repos/?p=plomrogue2;a=summary">source code</a> (includes proper terminal/ncurses client)
16 </div>
17 <div style="position: relative; display: inline-block;">
18 <pre id="terminal"></pre>
19 <textarea id="input" style="position: absolute; left: 0; height: 100%; width: 100%; opacity: 0; z-index: -1;"></textarea>
20 </div>
21 <h3>button controls for hard-to-remember keybindings</h3>
22 <table id="move_table" style="float: left">
23   <tr>
24     <td style="text-align: right"><button id="hex_move_upleft"></button></td>
25     <td style="text-align: center"><button id="square_move_up"></button></td>
26     <td><button id="hex_move_upright"></button></td>
27   </tr>
28   <tr>
29     <td style="text-align: right;"><button id="square_move_left"></button><button id="hex_move_left">left</button></td>
30     <td stlye="text-align: center;">move</td>
31     <td><button id="square_move_right"></button><button id="hex_move_right"></button></td>
32   </tr>
33   <tr>
34     <td><button id="hex_move_downleft"></button></td>
35     <td style="text-align: center"><button id="square_move_down"></button></td>
36     <td><button id="hex_move_downright"></button></td>
37   </tr>
38 </table>
39 <table>
40   <tr>
41     <td><button id="help"></button></td>
42   </tr>
43   <tr>
44     <td><button id="switch_to_chat"></button><br /></td>
45   </tr>
46   <tr>
47     <td><button id="switch_to_study"></button></td>
48     <td><button id="toggle_map_mode"></button>
49   </tr>
50   <tr>
51     <td><button id="switch_to_play"></button></td>
52     <td>
53       <button id="switch_to_take_thing"></button>
54       <button id="switch_to_drop_thing"></button>
55       <button id="door"></button>
56       <button id="consume"></button>
57       <button id="dance"></button>
58       <button id="switch_to_command_thing"></button>
59       <button id="teleport"></button>
60       <button id="wear"></button>
61       <button id="spin"></button>
62     </td>
63   </tr>
64   <tr>
65     <td><button id="switch_to_edit"></button></td>
66     <td>
67       <button id="switch_to_write"></button>
68       <button id="flatten"></button>
69       <button id="install"></button>
70       <button id="switch_to_annotate"></button>
71       <button id="switch_to_portal"></button>
72       <button id="switch_to_name_thing"></button>
73       <button id="switch_to_password"></button>
74       <button id="switch_to_enter_face"></button>
75       <button id="switch_to_enter_hat"></button>
76     </td>
77   </tr>
78   <tr>
79     <td><button id="switch_to_admin_enter"></button></td>
80     <td>
81       <button id="switch_to_control_pw_type"></button>
82       <button id="switch_to_control_tile_type"></button>
83       <button id="switch_to_admin_thing_protect"></button>
84       <button id="toggle_tile_draw"></button>
85     </td>
86   <tr>
87   </tr>
88 </table>
89 <h3>edit keybindings</h3> (see <a href="https://developer.mozilla.org/en-US/docs/Web/API/KeyboardEvent/key/Key_Values">here</a> for non-obvious available values):<br />
90 <ul>
91 <li>move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)
92 <li>move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)
93 <li>move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)
94 <li>move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)
95 <li>move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" />
96 <li>move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" />
97 <li>move right (hex grid): <input id="key_hex_move_right" type="text" value="d" />
98 <li>move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" />
99 <li>move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" />
100 <li>move left (hex grid): <input id="key_hex_move_left" type="text" value="a" />
101 <li>help: <input id="key_help" type="text" value="h" />
102 <li>flatten surroundings: <input id="key_flatten" type="text" value="F" />
103 <li>teleport: <input id="key_teleport" type="text" value="p" />
104 <li>spin: <input id="key_spin" type="text" value="S" />
105 <li>dance: <input id="key_dance" type="text" value="T" />
106 <li>open/close: <input id="key_door" type="text" value="D" />
107 <li>consume: <input id="key_consume" type="text" value="C" />
108 <li>install: <input id="key_install" type="text" value="I" />
109 <li>(un-)wear: <input id="key_wear" type="text" value="W" />
110 <li><input id="key_switch_to_drop_thing" type="text" value="u" />
111 <li><input id="key_switch_to_enter_face" type="text" value="f" />
112 <li><input id="key_switch_to_enter_hat" type="text" value="H" />
113 <li><input id="key_switch_to_take_thing" type="text" value="z" />
114 <li><input id="key_switch_to_chat" type="text" value="t" />
115 <li><input id="key_switch_to_play" type="text" value="p" />
116 <li><input id="key_switch_to_study" type="text" value="?" />
117 <li><input id="key_switch_to_edit" type="text" value="E" />
118 <li><input id="key_switch_to_write" type="text" value="m" />
119 <li><input id="key_switch_to_name_thing" type="text" value="N" />
120 <li><input id="key_switch_to_command_thing" type="text" value="O" />
121 <li><input id="key_switch_to_password" type="text" value="P" />
122 <li><input id="key_switch_to_admin_enter" type="text" value="A" />
123 <li><input id="key_switch_to_control_pw_type" type="text" value="C" />
124 <li><input id="key_switch_to_control_tile_type" type="text" value="Q" />
125 <li><input id="key_switch_to_admin_thing_protect" type="text" value="T" />
126 <li><input id="key_switch_to_annotate" type="text" value="M" />
127 <li><input id="key_switch_to_portal" type="text" value="T" />
128 <li>toggle map view: <input id="key_toggle_map_mode" type="text" value="L" />
129 <li>toggle protection character drawing: <input id="key_toggle_tile_draw" type="text" value="m" />
130 </ul>
131 </div>
132 <script>
133 "use strict";
134 let websocket_location = "wss://plomlompom.com/rogue_chat/";
135 //let websocket_location = "ws://localhost:8000/";
136
137 let mode_helps = {
138     'play': {
139         'short': 'play',
140         'intro': '',
141         'long': 'This mode allows you to interact with the map in various ways.'
142     },
143     'study': {
144         'short': 'study',
145         'intro': '',
146         'long': 'This mode allows you to study the map and its tiles in detail.  Move the question mark over a tile, and the right half of the screen will show detailed information on it.  Toggle the map view to show or hide different information layers.'},
147     'edit': {
148         'short': 'world edit',
149         'intro': '',
150         'long': 'This mode allows you to change the game world in various ways.  Individual map tiles can be protected by "protection characters", which you can see by toggling into the protections map view.  You can edit a tile if you set the world edit password that matches its protection character.  The character "." marks the absence of protection:  Such tiles can always be edited.'
151     },
152     'name_thing': {
153         'short': 'name thing',
154         'intro': '',
155         'long': 'Give name to/change name of carried thing.'
156     },
157     'command_thing': {
158         'short': 'command',
159         'intro': '',
160         'long': 'Enter a command to the thing you carry.  Enter nothing to return to play mode.'
161     },
162     'take_thing': {
163         'short': 'take',
164         'intro': 'Pick up a thing in reach by entering its index number.  Enter nothing to abort.',
165         'long': 'You see a list of things which you could pick up.  Enter the target thing\'s index, or, to leave, nothing.'
166     },
167     'drop_thing': {
168         'short': 'drop',
169         'intro': 'Enter number of direction to which you want to drop thing.',
170         'long': 'Drop currently carried thing by entering the target direction index.  Enter nothing to return to play mode..'
171     },
172     'admin_thing_protect': {
173         'short': 'change thing protection',
174         'intro': '@ enter thing protection character:',
175         'long': 'Change protection character for carried thing.'
176     },
177     'enter_face': {
178         'short': 'edit face',
179         'intro': '@ enter face line (enter nothing to abort):',
180         'long': 'Draw your face as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom.  Eat cookies to extend the ASCII characters available for drawing.'
181     },
182     'enter_hat': {
183         'short': 'edit hat',
184         'intro': '@ enter hat line (enter nothing to abort):',
185         'long': 'Draw your hat as ASCII art.  The string you enter must be 18 characters long, and will be divided on display into 3 lines of 6 characters each, from top to bottom..'
186     },
187     'write': {
188         'short': 'edit tile',
189         'intro': '',
190         'long': 'This mode allows you to change the map tile you currently stand on (if your world editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.'
191     },
192     'control_pw_type': {
193         'short': 'change protection character password',
194         'intro': '@ enter protection character for which you want to change the password:',
195         'long': 'This mode is the first of two steps to change the password for a protection character.  First enter the protection character for which you want to change the password.'
196     },
197     'control_pw_pw': {
198         'short': 'change protection character password',
199         'intro': '',
200         'long': 'This mode is the second of two steps to change the password for a protection character.  Enter the new password for the protection character you chose.'
201     },
202     'control_tile_type': {
203         'short': 'change tiles protection',
204         'intro': '@ enter protection character which you want to draw:',
205         'long': 'This mode is the first of two steps to change tile protection areas on the map.  First enter the tile protection character you want to write.'
206     },
207     'control_tile_draw': {
208         'short': 'change tiles protection',
209         'intro': '',
210         'long': 'This mode is the second of two steps to change tile protection areas on the map.  Toggle tile protection drawing on/off and move the ?? cursor around the map to draw the selected protection character.'
211     },
212     'annotate': {
213         'short': 'annotate tile',
214         'intro': '',
215         'long': 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your world editing password authorizes you so).  Hit Return to leave.'
216     },
217     'portal': {
218         'short': 'edit portal',
219         'intro': '',
220         'long': 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your world editing password authorizes you so).  Enter or edit a URL to imprint a teleportation target; enter emptiness to remove a pre-existing teleportation target.  Hit Return to leave.'
221     },
222     'chat': {
223         'short': 'chat',
224         'intro': '',
225         'long': 'This mode allows you to engage in chit-chat with other users.  Any line you enter into the input prompt that does not start with a "/" will be sent out to nearby players – but barriers and distance will reduce what they can read, so stand close to them to ensure they get your message.  Lines that start with a "/" are used for commands like:\n\n/nick NAME – re-name yourself to NAME'
226     },
227     'login': {
228         'short': 'login',
229         'intro': '',
230         'long': 'Enter your player name.'
231     },
232     'waiting_for_server': {
233         'short': 'waiting for server response',
234         'intro': '@ waiting for server …',
235         'long': 'Waiting for a server response.'
236     },
237     'post_login_wait': {
238         'short': 'waiting for server response',
239         'intro': '',
240         'long': 'Waiting for a server response.'
241     },
242     'password': {
243         'short': 'set world edit password',
244         'intro': '',
245         'long': 'This mode allows you to change the password that you send to authorize yourself for editing password-protected elements of the world.  Hit return to confirm and leave.'
246     },
247     'admin_enter': {
248         'short': 'become admin',
249         'intro': '@ enter admin password:',
250         'long': 'This mode allows you to become admin if you know an admin password.'
251     },
252     'admin': {
253         'short': 'admin',
254         'intro': '',
255         'long': 'This mode allows you access to actions limited to administrators.'
256     }
257 }
258 let key_descriptions = {
259     'help': 'help',
260     'flatten': 'flatten surroundings',
261     'teleport': 'teleport',
262     'door': 'open/close',
263     'consume': 'consume',
264     'install': '(un-)install',
265     'wear': '(un-)wear',
266     'spin': 'spin',
267     'dance': 'dance',
268     'toggle_map_mode': 'toggle map view',
269     'toggle_tile_draw': 'toggle protection character drawing',
270     'hex_move_upleft': 'up-left',
271     'hex_move_upright': 'up-right',
272     'hex_move_right': 'right',
273     'hex_move_left': 'left',
274     'hex_move_downleft': 'down-left',
275     'hex_move_downright': 'down-right',
276     'square_move_up': 'up',
277     'square_move_left': 'left',
278     'square_move_down': 'down',
279     'square_move_right': 'right',
280 };
281 for (const mode_name of Object.keys(mode_helps)) {
282     key_descriptions['switch_to_' + mode_name] = mode_helps[mode_name].short;
283 };
284
285 let rows_selector = document.getElementById("n_rows");
286 let cols_selector = document.getElementById("n_cols");
287 let key_selectors = document.querySelectorAll('[id^="key_"]');
288
289 for (const key_switch_selector of document.querySelectorAll('[id^="key_switch_to_"]')) {
290     const action = key_switch_selector.id.slice("key_switch_to_".length);
291     key_switch_selector.parentNode.prepend(mode_helps[action].short + ': ');
292 }
293
294 function restore_selector_value(selector) {
295     let stored_selection = window.localStorage.getItem(selector.id);
296     if (stored_selection) {
297         selector.value = stored_selection;
298     }
299 }
300 restore_selector_value(rows_selector);
301 restore_selector_value(cols_selector);
302 for (let key_selector of key_selectors) {
303     restore_selector_value(key_selector);
304 }
305
306 function escapeHTML(str) {
307     return str.
308         replace(/&/g, '&amp;').
309         replace(/</g, '&lt;').
310         replace(/>/g, '&gt;').
311         replace(/'/g, '&apos;').
312         replace(/"/g, '&quot;');
313 };
314
315 let terminal = {
316   initialize: function() {
317     this.rows = rows_selector.value;
318     this.cols = cols_selector.value;
319     this.pre_el = document.getElementById("terminal");
320     this.set_default_colors();
321     this.apply_colors();
322     this.content = [];
323       let line = []
324     for (let y = 0, x = 0; y <= this.rows; x++) {
325         if (x == this.cols) {
326             x = 0;
327             y += 1;
328             this.content.push(line);
329             line = [];
330             if (y == this.rows) {
331                 break;
332             }
333         }
334         line.push(' ');
335     }
336   },
337   apply_colors: function() {
338     this.pre_el.style.color = this.foreground;
339     this.pre_el.style.backgroundColor = this.background;
340   },
341   set_default_colors: function() {
342       this.foreground = 'white';
343       this.background = 'black';
344       this.apply_colors();
345   },
346   set_random_colors: function() {
347       function rand(offset) {
348           return Math.floor(offset + Math.random() * 96).toString(16).padStart(2, '0');
349       }
350       this.foreground = '#' + rand(159) + rand(159) + rand(159);
351       this.background = '#' + rand(0) + rand(0) + rand(0);
352       this.apply_colors();
353   },
354   blink_screen: function() {
355       this.pre_el.style.color = this.background;
356       this.pre_el.style.backgroundColor = this.foreground;
357       setTimeout(() => {
358           this.pre_el.style.color = this.foreground;
359           this.pre_el.style.backgroundColor = this.background;
360       }, 100);
361   },
362   refresh: function() {
363       let pre_content = '';
364       for (let y = 0; y < this.rows; y++) {
365           let line = this.content[y].join('');
366           let chunks = [];
367           if (y in tui.links) {
368               let start_x = 0;
369               for (let span of tui.links[y]) {
370                   chunks.push(escapeHTML(line.slice(start_x, span[0])));
371                   chunks.push('<a target="_blank" href="');
372                   chunks.push(escapeHTML(span[2]));
373                   chunks.push('">');
374                   chunks.push(escapeHTML(line.slice(span[0], span[1])));
375                   chunks.push('</a>');
376                   start_x = span[1];
377               }
378               chunks.push(escapeHTML(line.slice(start_x)));
379           } else {
380               chunks = [escapeHTML(line)];
381           }
382           for (const chunk of chunks) {
383               pre_content += chunk;
384           }
385           pre_content += '\n';
386       }
387       this.pre_el.innerHTML = pre_content;
388   },
389   write: function(start_y, start_x, msg) {
390       for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
391           this.content[start_y][x] = msg[i];
392       }
393   },
394   drawBox: function(start_y, start_x, height, width) {
395     let end_y = start_y + height;
396     let end_x = start_x + width;
397     for (let y = start_y, x = start_x; y < this.rows; x++) {
398       if (x == end_x) {
399         x = start_x;
400         y += 1;
401         if (y == end_y) {
402             break;
403         }
404       };
405       this.content[y][x] = ' ';
406     }
407   },
408 }
409 terminal.initialize();
410
411 let parser = {
412   tokenize: function(str) {
413     let tokens = [];
414     let token = ''
415     let quoted = false;
416     let escaped = false;
417     for (let i = 0; i < str.length; i++) {
418       let c = str[i];
419       if (quoted) {
420         if (escaped) {
421           token += c;
422           escaped = false;
423         } else if (c == '\\') {
424           escaped = true;
425         } else if (c == '"') {
426           quoted = false
427         } else {
428           token += c;
429         }
430       } else if (c == '"') {
431         quoted = true
432       } else if (c === ' ') {
433         if (token.length > 0) {
434           tokens.push(token);
435           token = '';
436         }
437       } else {
438         token += c;
439       }
440     }
441     if (token.length > 0) {
442       tokens.push(token);
443     }
444     return tokens;
445   },
446   parse_yx: function(position_string) {
447     let coordinate_strings = position_string.split(',')
448     let position = [0, 0];
449     position[0] = parseInt(coordinate_strings[0].slice(2));
450     position[1] = parseInt(coordinate_strings[1].slice(2));
451     return position;
452   },
453 }
454
455 class Thing {
456     constructor(yx) {
457         this.position = yx;
458     }
459 }
460
461 let server = {
462     init: function(url) {
463         this.url = url;
464         this.websocket = new WebSocket(this.url);
465         this.websocket.onopen = function(event) {
466             game.thing_types = {};
467             game.terrains = {};
468             server.send(['TASKS']);
469             server.send(['TERRAINS']);
470             server.send(['THING_TYPES']);
471             tui.log_msg("@ server connected! :)");
472             tui.switch_mode('login');
473         };
474         this.websocket.onclose = function(event) {
475             tui.switch_mode('waiting_for_server');
476             tui.log_msg("@ server disconnected :(");
477         };
478             this.websocket.onmessage = this.handle_event;
479         },
480     reconnect_to: function(url) {
481         this.websocket.close();
482         this.init(url);
483     },
484     send: function(tokens) {
485         this.websocket.send(unparser.untokenize(tokens));
486     },
487     handle_event: function(event) {
488         let tokens = parser.tokenize(event.data);
489         if (tokens[0] === 'TURN') {
490             game.turn_complete = false;
491         } else if (tokens[0] === 'OTHER_WIPE') {
492             game.portals_new = {};
493             explorer.annotations_new = {};
494             game.things_new = [];
495         } else if (tokens[0] === 'STATS') {
496             game.bladder_pressure_new = parseInt(tokens[1])
497             game.energy_new = parseInt(tokens[2])
498         } else if (tokens[0] === 'THING') {
499             let t = game.get_thing_temp(tokens[4], true);
500             t.position = parser.parse_yx(tokens[1]);
501             t.type_ = tokens[2];
502             t.protection = tokens[3];
503             t.portable = parseInt(tokens[5]);
504             t.commandable = parseInt(tokens[6]);
505         } else if (tokens[0] === 'THING_NAME') {
506             let t = game.get_thing_temp(tokens[1]);
507             t.name_ = tokens[2];
508         } else if (tokens[0] === 'THING_FACE') {
509             let t = game.get_thing_temp(tokens[1]);
510             t.face = tokens[2];
511         } else if (tokens[0] === 'THING_HAT') {
512             let t = game.get_thing_temp(tokens[1]);
513             t.hat = tokens[2];
514         } else if (tokens[0] === 'THING_CHAR') {
515             let t = game.get_thing_temp(tokens[1]);
516             t.thing_char = tokens[2];
517         } else if (tokens[0] === 'TASKS') {
518             game.tasks = tokens[1].split(',');
519             tui.mode_write.legal = game.tasks.includes('WRITE');
520             tui.mode_command_thing.legal = game.tasks.includes('WRITE');
521             tui.mode_take_thing.legal = game.tasks.includes('PICK_UP');
522             tui.mode_drop_thing.legal = game.tasks.includes('DROP');
523         } else if (tokens[0] === 'THING_TYPE') {
524             game.thing_types[tokens[1]] = tokens[2]
525         } else if (tokens[0] === 'THING_CARRYING') {
526             let t = game.get_thing_temp(tokens[1]);
527             t.carrying = game.get_thing_temp(tokens[2], false);
528         } else if (tokens[0] === 'THING_INSTALLED') {
529             let t = game.get_thing_temp(tokens[1]);
530             t.installed = true;
531         } else if (tokens[0] === 'TERRAIN') {
532             game.terrains[tokens[1]] = tokens[2]
533         } else if (tokens[0] === 'MAP') {
534             game.map_geometry_new = tokens[1];
535             game.map_size_new = parser.parse_yx(tokens[2]);
536             game.map_new = tokens[3]
537         } else if (tokens[0] === 'FOV') {
538             game.fov_new = tokens[1]
539         } else if (tokens[0] === 'MAP_CONTROL') {
540             game.map_control_new = tokens[1]
541         } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
542             game.portals = game.portals_new;
543             game.map_geometry = game.map_geometry_new;
544             game.map_size = game.map_size_new;
545             game.map = game.map_new;
546             game.fov = game.fov_new;
547             tui.init_keys();
548             game.map_control = game.map_control_new;
549             explorer.annotations = explorer.annotations_new;
550             explorer.info_cached = false;
551             game.things = game.things_new;
552             game.player = game.things[game.player_id];
553             game.players_hat_chars = game.players_hat_chars_new;
554             game.bladder_pressure = game.bladder_pressure_new
555             game.energy = game.energy_new
556             game.turn_complete = true;
557             if (tui.mode.name == 'post_login_wait') {
558                 tui.switch_mode('play');
559             } else {
560                 tui.full_refresh();
561             }
562         } else if (tokens[0] === 'CHAT') {
563              tui.log_msg('# ' + tokens[1], 1);
564         } else if (tokens[0] === 'CHATFACE') {
565             tui.draw_face = tokens[1];
566             tui.full_refresh();
567         } else if (tokens[0] === 'REPLY') {
568              tui.log_msg('#MUSICPLAYER: ' + tokens[1], 1);
569         } else if (tokens[0] === 'PLAYER_ID') {
570             game.player_id = parseInt(tokens[1]);
571         } else if (tokens[0] === 'PLAYERS_HAT_CHARS') {
572             game.players_hat_chars_new = tokens[1];
573         } else if (tokens[0] === 'LOGIN_OK') {
574             this.send(['GET_GAMESTATE']);
575             tui.switch_mode('post_login_wait');
576             tui.log_msg('@ welcome!')
577             tui.log_msg('@ hint: see top of terminal for how to get help.')
578             tui.log_msg('@ hint: enter study mode to understand your environment.')
579         } else if (tokens[0] === 'DEFAULT_COLORS') {
580             terminal.set_default_colors();
581         } else if (tokens[0] === 'RANDOM_COLORS') {
582             terminal.set_random_colors();
583         } else if (tokens[0] === 'ADMIN_OK') {
584             tui.is_admin = true;
585             tui.log_msg('@ you now have admin rights');
586             tui.switch_mode('admin');
587         } else if (tokens[0] === 'PORTAL') {
588             let position = parser.parse_yx(tokens[1]);
589             game.portals_new[position] = tokens[2];
590         } else if (tokens[0] === 'ANNOTATION') {
591             let position = parser.parse_yx(tokens[1]);
592             explorer.annotations_new[position] = tokens[2];
593         } else if (tokens[0] === 'UNHANDLED_INPUT') {
594             tui.log_msg('? unknown command');
595         } else if (tokens[0] === 'PLAY_ERROR') {
596             tui.log_msg('? ' + tokens[1]);
597             terminal.blink_screen();
598         } else if (tokens[0] === 'ARGUMENT_ERROR') {
599             tui.log_msg('? syntax error: ' + tokens[1]);
600         } else if (tokens[0] === 'GAME_ERROR') {
601             tui.log_msg('? game error: ' + tokens[1]);
602         } else if (tokens[0] === 'PONG') {
603             ;
604         } else {
605             tui.log_msg('? unhandled input: ' + event.data);
606         }
607     }
608 }
609
610 let unparser = {
611     quote: function(str) {
612         let quoted = ['"'];
613         for (let i = 0; i < str.length; i++) {
614             let c = str[i];
615             if (['"', '\\'].includes(c)) {
616                 quoted.push('\\');
617             };
618             quoted.push(c);
619         }
620         quoted.push('"');
621         return quoted.join('');
622     },
623     to_yx: function(yx_coordinate) {
624         return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
625     },
626     untokenize: function(tokens) {
627         let quoted_tokens = [];
628         for (let token of tokens) {
629             quoted_tokens.push(this.quote(token));
630         }
631         return quoted_tokens.join(" ");
632     }
633 }
634
635 class Mode {
636     constructor(name, has_input_prompt=false, shows_info=false,
637                 is_intro=false, is_single_char_entry=false) {
638         this.name = name;
639         this.short_desc = mode_helps[name].short;
640         this.available_modes = [];
641         this.available_actions = [];
642         this.has_input_prompt = has_input_prompt;
643         this.shows_info= shows_info;
644         this.is_intro = is_intro;
645         this.help_intro = mode_helps[name].long;
646         this.intro_msg = mode_helps[name].intro;
647         this.is_single_char_entry = is_single_char_entry;
648         this.legal = true;
649     }
650     *iter_available_modes() {
651         for (let mode_name of this.available_modes) {
652             let mode = tui['mode_' + mode_name];
653             if (!mode.legal) {
654                 continue;
655             }
656             let key = tui.keys['switch_to_' + mode.name];
657             yield [mode, key]
658         }
659     }
660     list_available_modes() {
661         let msg = ''
662         if (this.available_modes.length > 0) {
663             msg += 'Other modes available from here:\n';
664             for (let [mode, key] of this.iter_available_modes()) {
665                 msg += '[' + key + '] – ' + mode.short_desc + '\n';
666             }
667         }
668         return msg;
669     }
670     mode_switch_on_key(key_event) {
671         for (let [mode, key] of this.iter_available_modes()) {
672             if (key_event.key == key) {
673                 event.preventDefault();
674                 tui.switch_mode(mode.name);
675                 return true;
676             };
677         }
678         return false;
679     }
680 }
681 let tui = {
682   links: {},
683   log: [],
684   input_prompt: '> ',
685   input_lines: [],
686   window_width: terminal.cols / 2,
687   height_turn_line: 1,
688   height_mode_line: 1,
689   height_input: 1,
690   password: 'foo',
691   show_help: false,
692   is_admin: false,
693   tile_draw: false,
694   mode_waiting_for_server: new Mode('waiting_for_server',
695                                      false, false, true),
696   mode_login: new Mode('login', true, false, true),
697   mode_post_login_wait: new Mode('post_login_wait'),
698   mode_chat: new Mode('chat', true),
699   mode_annotate: new Mode('annotate', true, true),
700   mode_play: new Mode('play'),
701   mode_study: new Mode('study', false, true),
702   mode_write: new Mode('write', false, false, false, true),
703   mode_edit: new Mode('edit'),
704   mode_control_pw_type: new Mode('control_pw_type', true),
705   mode_admin_thing_protect: new Mode('admin_thing_protect', true),
706   mode_portal: new Mode('portal', true, true),
707   mode_password: new Mode('password', true),
708   mode_name_thing: new Mode('name_thing', true, true),
709   mode_command_thing: new Mode('command_thing', true),
710   mode_take_thing: new Mode('take_thing', true),
711   mode_drop_thing: new Mode('drop_thing', true),
712   mode_enter_face: new Mode('enter_face', true),
713   mode_enter_hat: new Mode('enter_hat', true),
714   mode_admin_enter: new Mode('admin_enter', true),
715   mode_admin: new Mode('admin'),
716   mode_control_pw_pw: new Mode('control_pw_pw', true),
717   mode_control_tile_type: new Mode('control_tile_type', true),
718   mode_control_tile_draw: new Mode('control_tile_draw'),
719   action_tasks: {
720       'flatten': 'FLATTEN_SURROUNDINGS',
721       'take_thing': 'PICK_UP',
722       'drop_thing': 'DROP',
723       'move': 'MOVE',
724       'door': 'DOOR',
725       'install': 'INSTALL',
726       'wear': 'WEAR',
727       'command': 'COMMAND',
728       'consume': 'INTOXICATE',
729       'spin': 'SPIN',
730       'dance': 'DANCE',
731   },
732   offset: [0,0],
733   map_lines: [],
734   ascii_draw_stage: 0,
735   full_ascii_draw: '',
736   selectables: [],
737   draw_face: false,
738   init: function() {
739       this.mode_play.available_modes = ["chat", "study", "edit", "admin_enter",
740                                         "command_thing", "take_thing", "drop_thing"]
741       this.mode_play.available_actions = ["move", "teleport", "door", "consume",
742                                           "wear", "spin", "dance"];
743       this.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
744       this.mode_study.available_actions = ["toggle_map_mode", "move_explorer"];
745       this.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
746                                          "control_tile_type", "chat",
747                                          "study", "play", "edit"]
748       this.mode_admin.available_actions = ["move", "toggle_map_mode"];
749       this.mode_control_tile_draw.available_modes = ["admin_enter"]
750       this.mode_control_tile_draw.available_actions = ["toggle_tile_draw"];
751       this.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
752                                         "password", "chat", "study", "play",
753                                         "admin_enter", "enter_face", "enter_hat"]
754       this.mode_edit.available_actions = ["move", "flatten", "install",
755                                           "toggle_map_mode"]
756       this.inputEl = document.getElementById("input");
757       this.switch_mode('waiting_for_server');
758       this.recalc_input_lines();
759       this.height_header = this.height_turn_line + this.height_mode_line;
760       this.init_keys();
761   },
762   init_keys: function() {
763     document.getElementById("move_table").hidden = true;
764     this.keys = {};
765     for (let key_selector of key_selectors) {
766         this.keys[key_selector.id.slice(4)] = key_selector.value;
767     }
768     this.movement_keys = {};
769     let geometry_prefix = 'undefinedMapGeometry_';
770     if (game.map_geometry) {
771         geometry_prefix = game.map_geometry.toLowerCase() + '_';
772     }
773     for (const key_name of Object.keys(key_descriptions)) {
774         if (key_name.startsWith(geometry_prefix)) {
775             let direction = key_name.split('_')[2].toUpperCase();
776             let key = this.keys[key_name];
777             this.movement_keys[key] = direction;
778         }
779     };
780     for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
781         if (move_button.id.startsWith('key_')) {
782             continue;
783         }
784         move_button.hidden = true;
785     };
786     for (const move_button of document.querySelectorAll('[id^="' + geometry_prefix + 'move_"]')) {
787         document.getElementById("move_table").hidden = false;
788         move_button.hidden = false;
789     };
790     for (let el of document.getElementsByTagName("button")) {
791       let action_desc = key_descriptions[el.id];
792       let action_key = '[' + this.keys[el.id] + ']';
793       el.innerHTML = escapeHTML(action_desc) + '<br /><span class="keyboard_controlled">' + escapeHTML(action_key) + '</span>';
794     }
795   },
796   task_action_on: function(action) {
797       return game.tasks.includes(this.action_tasks[action]);
798   },
799   switch_mode: function(mode_name) {
800
801     function fail(msg, return_mode='play') {
802         tui.log_msg('? ' + msg);
803         terminal.blink_screen();
804         tui.switch_mode(return_mode);
805     }
806
807     if (this.mode && this.mode.name == 'control_tile_draw') {
808         tui.log_msg('@ finished tile protection drawing.')
809     }
810     this.draw_face = false;
811     this.tile_draw = false;
812     if (mode_name == 'command_thing' && (!game.player.carrying
813                                          || !game.player.carrying.commandable)) {
814         return fail('not carrying anything commandable');
815     } else if (mode_name == 'name_thing' && !game.player.carrying) {
816         return fail('not carrying anything to re-name', 'edit');
817     } else if (mode_name == 'admin_thing_protect' && !game.player.carrying) {
818         return fail('not carrying anything to protect')
819     } else if (mode_name == 'take_thing' && game.player.carrying) {
820         return fail('already carrying something');
821     } else if (mode_name == 'drop_thing' && !game.player.carrying) {
822         return fail('not carrying anything droppable');
823     } else if (mode_name == 'enter_hat' && !game.player.hat) {
824         return fail('not wearing hat to edit', 'edit');
825     }
826     if (mode_name == 'admin_enter' && this.is_admin) {
827         mode_name = 'admin';
828     };
829     this.mode = this['mode_' + mode_name];
830     if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
831         this.map_mode = 'protections';
832     } else if (this.mode.name != "edit") {
833         this.map_mode = 'terrain + things';
834     };
835     if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
836         explorer.position = game.player.position;
837     }
838     this.inputEl.value = "";
839     this.restore_input_values();
840     for (let el of document.getElementsByTagName("button")) {
841         el.disabled = true;
842     }
843     document.getElementById("help").disabled = false;
844     for (const action of this.mode.available_actions) {
845         if (["move", "move_explorer"].includes(action)) {
846             for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
847                 move_key.disabled = false;
848             }
849         } else if (Object.keys(this.action_tasks).includes(action)) {
850             if (this.task_action_on(action)) {
851                 document.getElementById(action).disabled = false;
852             }
853         } else {
854             document.getElementById(action).disabled = false;
855         };
856     }
857     for (const mode_name of this.mode.available_modes) {
858             document.getElementById('switch_to_' + mode_name).disabled = false;
859     }
860     if (this.mode.intro_msg.length > 0) {
861         this.log_msg(this.mode.intro_msg);
862     }
863     if (this.mode.name == 'login') {
864         if (this.login_name) {
865             server.send(['LOGIN', this.login_name]);
866         } else {
867             this.log_msg("? need login name");
868         }
869     } else if (this.mode.is_single_char_entry) {
870         this.show_help = true;
871     } else if (this.mode.name == 'take_thing') {
872         this.log_msg("Portable things in reach for pick-up:");
873         const y = game.player.position[0]
874         const x = game.player.position[1]
875         let directed_moves = {
876             'HERE': [0, 0], 'LEFT': [0, -1], 'RIGHT': [0, 1]
877         }
878         if (game.map_geometry == 'Square') {
879             directed_moves['UP'] = [-1, 0];
880             directed_moves['DOWN'] = [1, 0];
881         } else if (game.map_geometry == 'Hex') {
882             if (y % 2) {
883                 directed_moves['UPLEFT'] = [-1, 0];
884                 directed_moves['UPRIGHT'] = [-1, 1];
885                 directed_moves['DOWNLEFT'] = [1, 0];
886                 directed_moves['DOWNRIGHT'] = [1, 1];
887             } else {
888                 directed_moves['UPLEFT'] = [-1, -1];
889                 directed_moves['UPRIGHT'] = [-1, 0];
890                 directed_moves['DOWNLEFT'] = [1, -1];
891                 directed_moves['DOWNRIGHT'] = [1, 0];
892             }
893         }
894         console.log(directed_moves);
895         let select_range = {};
896         for (const direction in directed_moves) {
897             const move = directed_moves[direction];
898             select_range[direction] = [y + move[0], x + move[1]];
899         }
900         this.selectables = [];
901         let directions = [];
902         for (const direction in select_range) {
903             for (const t_id in game.things) {
904                 const t = game.things[t_id];
905                 const position = select_range[direction];
906                 if (t.portable
907                     && t.position[0] == position[0]
908                     && t.position[1] == position[1]) {
909                     this.selectables.push(t_id);
910                     directions.push(direction);
911                 }
912             }
913         }
914         if (this.selectables.length == 0) {
915             this.log_msg('none');
916             terminal.blink_screen();
917             this.switch_mode('play');
918             return;
919         } else {
920             for (let [i, t_id] of this.selectables.entries()) {
921                 const t = game.things[t_id];
922                 const direction = directions[i];
923                 this.log_msg(i + ' ' + direction + ': ' + explorer.get_thing_info(t));
924             }
925         }
926     } else if (this.mode.name == 'drop_thing') {
927         this.log_msg('Direction to drop thing to:');
928         this.selectables = ['HERE'].concat(Object.values(this.movement_keys));
929         for (let [i, direction] of this.selectables.entries()) {
930             this.log_msg(i + ': ' + direction);
931         };
932     } else if (this.mode.name == 'enter_hat') {
933         this.log_msg('legal characters: ' + game.players_hat_chars);
934     } else if (this.mode.name == 'command_thing') {
935         server.send(['TASK:COMMAND', 'HELP']);
936     } else if (this.mode.name == 'control_pw_pw') {
937         this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
938     } else if (this.mode.name == 'control_tile_draw') {
939         this.log_msg('@ can draw protection character "' + this.tile_control_char + '", turn drawing on/off with [' + this.keys.toggle_tile_draw + '], finish with [' +  this.keys.switch_to_admin_enter + '].')
940     }
941     this.full_refresh();
942   },
943   offset_links: function(offset, links) {
944       for (let y in links) {
945           let real_y = offset[0] + parseInt(y);
946           if (!this.links[real_y]) {
947               this.links[real_y] = [];
948           }
949           for (let link of links[y]) {
950               const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
951               this.links[real_y].push(offset_link);
952           }
953       }
954   },
955   restore_input_values: function() {
956       if (this.mode.name == 'annotate' && explorer.position in explorer.annotations) {
957           let info = explorer.annotations[explorer.position];
958           if (info != "(none)") {
959               this.inputEl.value = info;
960           }
961       } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
962           let portal = game.portals[explorer.position]
963           this.inputEl.value = portal;
964       } else if (this.mode.name == 'password') {
965           this.inputEl.value = this.password;
966       } else if (this.mode.name == 'name_thing') {
967           if (game.player.carrying && game.player.carrying.name_) {
968               this.inputEl.value = game.player.carrying.name_;
969           }
970       } else if (this.mode.name == 'admin_thing_protect') {
971           if (game.player.carrying && game.player.carrying.protection) {
972               this.inputEl.value = game.player.carrying.protection;
973           }
974       } else if (['enter_face', 'enter_hat'].includes(this.mode.name)) {
975           const start = this.ascii_draw_stage * 6;
976           const end = (this.ascii_draw_stage + 1) * 6;
977           if (this.mode.name == 'enter_face') {
978               this.inputEl.value = game.player.face.slice(start, end);
979           } else if (this.mode.name == 'enter_hat') {
980               this.inputEl.value = game.player.hat.slice(start, end);
981           }
982       }
983   },
984   recalc_input_lines: function() {
985       if (this.mode.has_input_prompt) {
986           let _ = null;
987           [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value + '█', this.window_width);
988       } else {
989           this.input_lines = [];
990       }
991       this.height_input = this.input_lines.length;
992   },
993   msg_into_lines_of_width: function(msg, width) {
994       function push_inner_link(y, end_x) {
995           if (!inner_links[y]) {
996               inner_links[y] = [];
997           };
998           inner_links[y].push([url_start_x, end_x, url]);
999       };
1000       let link_data = {};
1001       let url_ends = [];
1002       const regexp = RegExp('https?://[^\\s]+', 'g');
1003       let match;
1004       while ((match = regexp.exec(msg)) !== null) {
1005           const url = match[0];
1006           const url_start = match.index;
1007           const url_end = match.index + match[0].length;
1008           link_data[url_start] = url;
1009           url_ends.push(url_end);
1010       }
1011       let url_start_x = 0;
1012       let url = '';
1013       let inner_links = {};
1014       let in_link = false;
1015       let chunk = "";
1016       let lines = [];
1017       for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
1018           if (x >= width || msg[i] == "\n") {
1019               if (in_link) {
1020                   push_inner_link(y, chunk.length);
1021                   url_start_x = 0;
1022                   if (url_ends[0] == i) {
1023                       in_link = false;
1024                       url_ends.shift();
1025                   }
1026               };
1027               lines.push(chunk);
1028               chunk = "";
1029               x = 0;
1030               if (msg[i] == "\n") {
1031                   x -= 1;
1032               };
1033               y += 1;
1034           };
1035           if (msg[i] != "\n") {
1036               chunk += msg[i];
1037           };
1038           if (i in link_data) {
1039               url_start_x = x;
1040               url = link_data[i];
1041               in_link = true;
1042           } else if (url_ends[0] == i) {
1043               url_ends.shift();
1044               push_inner_link(y, x);
1045               in_link = false;
1046           }
1047       }
1048       lines.push(chunk);
1049       if (in_link) {
1050           push_inner_link(lines.length - 1, chunk.length);
1051       }
1052       return [lines, inner_links];
1053   },
1054   log_msg: function(msg) {
1055       this.log.push(msg);
1056       while (this.log.length > 100) {
1057         this.log.shift();
1058       };
1059       this.full_refresh();
1060   },
1061   pick_selectable: function(task_name) {
1062       const i = parseInt(this.inputEl.value);
1063       if (isNaN(this.inputEl.value) || i < 0 || i >= this.selectables.length) {
1064           tui.log_msg('? invalid index, aborted');
1065       } else {
1066           server.send(['TASK:' + task_name, tui.selectables[i]]);
1067       }
1068       this.inputEl.value = "";
1069       this.switch_mode('play');
1070   },
1071   enter_ascii_art: function(command) {
1072       if (this.inputEl.value.length > 6) {
1073           this.log_msg('? wrong input length, must be max 6; try again');
1074           return;
1075       } else if (this.inputEl.value.length < 6) {
1076           while (this.inputEl.value.length < 6) {
1077               this.inputEl.value += ' ';
1078           }
1079       }
1080       this.log_msg('  ' + this.inputEl.value);
1081       this.full_ascii_draw += this.inputEl.value;
1082       this.ascii_draw_stage += 1;
1083       if (this.ascii_draw_stage < 3) {
1084           this.restore_input_values();
1085       } else {
1086           server.send([command, this.full_ascii_draw]);
1087           this.full_ascii_draw = '';
1088           this.ascii_draw_stage = 0;
1089           this.inputEl.value = '';
1090           this.switch_mode('edit');
1091       }
1092   },
1093   draw_map: function() {
1094     if (!game.turn_complete && this.map_lines.length == 0) {
1095         return;
1096     }
1097     if (game.turn_complete) {
1098         let map_lines_split = [];
1099         let line = [];
1100         for (let i = 0, j = 0; i < game.map.length; i++, j++) {
1101             if (j == game.map_size[1]) {
1102                 map_lines_split.push(line);
1103                 line = [];
1104                 j = 0;
1105             };
1106             if (this.map_mode == 'protections') {
1107                 line.push(game.map_control[i] + ' ');
1108             } else {
1109                 line.push(game.map[i] + ' ');
1110             }
1111         };
1112         map_lines_split.push(line);
1113         if (this.map_mode == 'terrain + annotations') {
1114             for (const [coordinate, _] of Object.entries(explorer.annotations)) {
1115                 const yx = coordinate.split(',')
1116                 map_lines_split[yx[0]][yx[1]] = 'A ';
1117             }
1118         } else if (this.map_mode == 'terrain + things') {
1119             for (const p in game.portals) {
1120                 let coordinate = p.split(',')
1121                 let original = map_lines_split[coordinate[0]][coordinate[1]];
1122                 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
1123             }
1124             let used_positions = [];
1125             function draw_thing(t, used_positions) {
1126                 let symbol = game.thing_types[t.type_];
1127                 let meta_char = ' ';
1128                 if (t.thing_char) {
1129                     meta_char = t.thing_char;
1130                 }
1131                 if (used_positions.includes(t.position.toString())) {
1132                     meta_char = '+';
1133                 };
1134                 if (t.carrying) {
1135                     meta_char = '$';
1136                 }
1137                 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
1138                 used_positions.push(t.position.toString());
1139             }
1140             for (const thing_id in game.things) {
1141                 let t = game.things[thing_id];
1142                 if (t.type_ != 'Player') {
1143                     draw_thing(t, used_positions);
1144                 }
1145             };
1146             for (const thing_id in game.things) {
1147                 let t = game.things[thing_id];
1148                 if (t.type_ == 'Player') {
1149                     draw_thing(t, used_positions);
1150                 }
1151             };
1152         }
1153         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1154             map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
1155         } else if (tui.map_mode != 'terrain + things') {
1156             map_lines_split[game.player.position[0]][game.player.position[1]] = '??';
1157         }
1158         this.map_lines = []
1159         if (game.map_geometry == 'Square') {
1160             for (let line_split of map_lines_split) {
1161                 this.map_lines.push(line_split.join(''));
1162             };
1163         } else if (game.map_geometry == 'Hex') {
1164             let indent = 0
1165             for (let line_split of map_lines_split) {
1166                 this.map_lines.push(' '.repeat(indent) + line_split.join(''));
1167                 if (indent == 0) {
1168                     indent = 1;
1169                 } else {
1170                     indent = 0;
1171                 };
1172             };
1173         }
1174         let window_center = [terminal.rows / 2, this.window_width / 2];
1175         let center_position = [game.player.position[0], game.player.position[1]];
1176         if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
1177             center_position = [explorer.position[0], explorer.position[1]];
1178         }
1179         center_position[1] = center_position[1] * 2;
1180         this.offset = [center_position[0] - window_center[0],
1181                        center_position[1] - window_center[1]]
1182         if (game.map_geometry == 'Hex' && this.offset[0] % 2) {
1183             this.offset[1] += 1;
1184         };
1185     };
1186     let term_y = Math.max(0, -this.offset[0]);
1187     let term_x = Math.max(0, -this.offset[1]);
1188     let map_y = Math.max(0, this.offset[0]);
1189     let map_x = Math.max(0, this.offset[1]);
1190     for (; term_y < terminal.rows && map_y < this.map_lines.length; term_y++, map_y++) {
1191         let to_draw = this.map_lines[map_y].slice(map_x, this.window_width + this.offset[1]);
1192         terminal.write(term_y, term_x, to_draw);
1193     }
1194   },
1195   draw_face_popup: function() {
1196       const t = game.things[this.draw_face];
1197       if (!t || !t.face) {
1198           this.draw_face = false;
1199           return;
1200       }
1201       const start_x = tui.window_width - 10;
1202       let t_char = ' ';
1203       if (t.thing_char) {
1204           t_char = t.thing_char;
1205       }
1206       function draw_body_part(body_part, end_y) {
1207           terminal.write(end_y - 4, start_x, ' _[ @' + t_char + ' ]_ ');
1208           terminal.write(end_y - 3, start_x, '|        |');
1209           terminal.write(end_y - 2, start_x, '| ' + body_part.slice(0, 6) + ' |');
1210           terminal.write(end_y - 1, start_x, '| ' + body_part.slice(6, 12) + ' |');
1211           terminal.write(end_y, start_x, '| ' + body_part.slice(12, 18) + ' |');
1212       }
1213       if (t.face) {
1214           draw_body_part(t.face, terminal.rows - 2);
1215       }
1216       if (t.hat) {
1217           draw_body_part(t.hat, terminal.rows - 5);
1218       }
1219       terminal.write(terminal.rows - 1, start_x, '|        |');
1220   },
1221   draw_mode_line: function() {
1222       let help = 'hit [' + this.keys.help + '] for help';
1223       if (this.mode.has_input_prompt) {
1224           help = 'enter /help for help';
1225       }
1226       terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
1227   },
1228   draw_stats_line: function(n) {
1229       terminal.write(1, this.window_width,
1230                      'ENERGY: ' + game.energy +
1231                      ' BLADDER: ' + game.bladder_pressure);
1232   },
1233   draw_history: function() {
1234       let log_display_lines = [];
1235       let log_links = {};
1236       let y_offset_in_log = 0;
1237       for (let line of this.log) {
1238           let [new_lines, link_data] = this.msg_into_lines_of_width(line,
1239                                                                     this.window_width)
1240           log_display_lines = log_display_lines.concat(new_lines);
1241           for (const y in link_data) {
1242               const rel_y = y_offset_in_log + parseInt(y);
1243               log_links[rel_y] = [];
1244               for (let link of link_data[y]) {
1245                   log_links[rel_y].push(link);
1246               }
1247           }
1248           y_offset_in_log += new_lines.length;
1249       };
1250       let i = log_display_lines.length - 1;
1251       for (let y = terminal.rows - 1 - this.height_input;
1252            y >= this.height_header && i >= 0;
1253            y--, i--) {
1254           terminal.write(y, this.window_width, log_display_lines[i]);
1255       }
1256       for (const key of Object.keys(log_links)) {
1257           if (parseInt(key) <= i) {
1258               delete log_links[key];
1259           }
1260       }
1261       let offset = [terminal.rows - this.height_input - log_display_lines.length,
1262                     this.window_width];
1263       this.offset_links(offset, log_links);
1264   },
1265   draw_info: function() {
1266       const info = "MAP VIEW: " + tui.map_mode + "\n" + explorer.get_info();
1267       let [lines, link_data] = this.msg_into_lines_of_width(info, this.window_width);
1268       let offset = [this.height_header, this.window_width];
1269       for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1270         terminal.write(y, offset[1], lines[i]);
1271       }
1272       this.offset_links(offset, link_data);
1273   },
1274   draw_input: function() {
1275     if (this.mode.has_input_prompt) {
1276         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
1277             terminal.write(y, this.window_width, this.input_lines[i]);
1278         }
1279     }
1280   },
1281   draw_help: function() {
1282       let movement_keys_desc = '';
1283       if (!this.mode.is_intro) {
1284           movement_keys_desc = Object.keys(this.movement_keys).join(',');
1285       }
1286       let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1287       if (this.mode.available_actions.length > 0) {
1288           content += "Available actions:\n";
1289           for (let action of this.mode.available_actions) {
1290               if (Object.keys(this.action_tasks).includes(action)) {
1291                   if (!this.task_action_on(action)) {
1292                       continue;
1293                   }
1294               }
1295               if (action == 'move_explorer') {
1296                   action = 'move';
1297               }
1298               if (action == 'move') {
1299                   content += "[" + movement_keys_desc + "] – move\n"
1300               } else {
1301                   content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1302               }
1303           }
1304           content += '\n';
1305       }
1306       content += this.mode.list_available_modes();
1307       let start_x = 0;
1308       if (!this.mode.has_input_prompt) {
1309           start_x = this.window_width;
1310           this.draw_links = false;
1311       }
1312       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1313       let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1314       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1315           terminal.write(y, start_x, lines[i]);
1316       }
1317   },
1318   toggle_tile_draw: function() {
1319       if (tui.tile_draw) {
1320           tui.tile_draw = false;
1321       } else {
1322           tui.tile_draw = true;
1323       }
1324   },
1325   toggle_map_mode: function() {
1326       if (tui.map_mode == 'terrain only') {
1327           tui.map_mode = 'terrain + annotations';
1328       } else if (tui.map_mode == 'terrain + annotations') {
1329           tui.map_mode = 'terrain + things';
1330       } else if (tui.map_mode == 'terrain + things') {
1331           tui.map_mode = 'protections';
1332       } else if (tui.map_mode == 'protections') {
1333           tui.map_mode = 'terrain only';
1334       }
1335   },
1336   full_refresh: function() {
1337     this.draw_links = true;
1338     this.links = {};
1339     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1340     this.recalc_input_lines();
1341     if (this.mode.is_intro) {
1342         this.draw_history();
1343         this.draw_input();
1344     } else {
1345         this.draw_map();
1346         this.draw_stats_line();
1347         this.draw_mode_line();
1348         if (this.mode.shows_info) {
1349           this.draw_info();
1350         } else {
1351           this.draw_history();
1352         }
1353         this.draw_input();
1354     }
1355     if (this.show_help) {
1356         this.draw_help();
1357     }
1358     if (this.draw_face && ['chat', 'play'].includes(this.mode.name)) {
1359         this.draw_face_popup();
1360     }
1361     if (!this.draw_links) {
1362         this.links = {};
1363     }
1364     terminal.refresh();
1365   }
1366 }
1367
1368 let game = {
1369     init: function() {
1370         this.turn = -1;
1371         this.player_id = -1;
1372         this.tasks = {};
1373         this.things = {};
1374         this.things_new = {};
1375         this.fov = "";
1376         this.fov_new = "";
1377         this.map = "";
1378         this.map_new = "";
1379         this.map_control = "";
1380         this.map_control_new = "";
1381         this.map_size = [0,0];
1382         this.map_size_new = [0,0];
1383         this.portals = {};
1384         this.portals_new = {};
1385         this.players_hat_chars = "";
1386         this.bladder_pressure = 0;
1387         this.bladder_pressure_new = 0;
1388     },
1389     get_thing_temp: function(id_, create_if_not_found=false) {
1390         if (id_ in game.things_new) {
1391             return game.things_new[id_];
1392         } else if (create_if_not_found) {
1393             let t = new Thing([0,0]);
1394             game.things_new[id_] = t;
1395             return t;
1396         };
1397     },
1398     get_thing: function(id_, create_if_not_found=false) {
1399         if (id_ in game.things) {
1400             return game.things[id_];
1401         };
1402     },
1403     move: function(start_position, direction) {
1404         let target = [start_position[0], start_position[1]];
1405         if (direction == 'LEFT') {
1406             target[1] -= 1;
1407         } else if (direction == 'RIGHT') {
1408             target[1] += 1;
1409         } else if (game.map_geometry == 'Square') {
1410             if (direction == 'UP') {
1411                 target[0] -= 1;
1412             } else if (direction == 'DOWN') {
1413                 target[0] += 1;
1414             };
1415         } else if (game.map_geometry == 'Hex') {
1416             let start_indented = start_position[0] % 2;
1417             if (direction == 'UPLEFT') {
1418                 target[0] -= 1;
1419                 if (!start_indented) {
1420                     target[1] -= 1;
1421                 }
1422             } else if (direction == 'UPRIGHT') {
1423                 target[0] -= 1;
1424                 if (start_indented) {
1425                     target[1] += 1;
1426                 }
1427             } else if (direction == 'DOWNLEFT') {
1428                 target[0] += 1;
1429                 if (!start_indented) {
1430                     target[1] -= 1;
1431                 }
1432             } else if (direction == 'DOWNRIGHT') {
1433                 target[0] += 1;
1434                 if (start_indented) {
1435                     target[1] += 1;
1436                 }
1437             };
1438         };
1439         if (target[0] < 0 || target[1] < 0 ||
1440             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1441             return null;
1442         };
1443         return target;
1444     },
1445     teleport: function() {
1446         if (game.player.position in this.portals) {
1447             server.reconnect_to(this.portals[game.player.position]);
1448         } else {
1449             terminal.blink_screen();
1450             tui.log_msg('? not standing on portal')
1451         }
1452     }
1453 }
1454
1455 game.init();
1456 tui.init();
1457 tui.full_refresh();
1458 server.init(websocket_location);
1459
1460 let explorer = {
1461     position: [0,0],
1462     annotations: {},
1463     annotations_new: {},
1464     info_cached: false,
1465     move: function(direction) {
1466         let target = game.move(this.position, direction);
1467         if (target) {
1468             this.position = target
1469             this.info_cached = false;
1470             if (tui.tile_draw) {
1471                 this.send_tile_control_command();
1472             }
1473         } else {
1474             terminal.blink_screen();
1475         };
1476     },
1477     get_info: function() {
1478         if (this.info_cached) {
1479             return this.info_cached;
1480         }
1481         let info_to_cache = '';
1482         let position_i = this.position[0] * game.map_size[1] + this.position[1];
1483         if (game.fov[position_i] != '.') {
1484             info_to_cache += 'outside field of view';
1485         } else {
1486             for (let t_id in game.things) {
1487                  let t = game.things[t_id];
1488                  if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1489                      info_to_cache += "THING: " + this.get_thing_info(t);
1490                      let protection = t.protection;
1491                      if (protection == '.') {
1492                          protection = 'none';
1493                      }
1494                      info_to_cache += " / protection: " + protection + "\n";
1495                      if (t.hat) {
1496                          info_to_cache += t.hat.slice(0, 6) + '\n';
1497                          info_to_cache += t.hat.slice(6, 12) + '\n';
1498                          info_to_cache += t.hat.slice(12, 18) + '\n';
1499                      }
1500                      if (t.face) {
1501                          info_to_cache += t.face.slice(0, 6) + '\n';
1502                          info_to_cache += t.face.slice(6, 12) + '\n';
1503                          info_to_cache += t.face.slice(12, 18) + '\n';
1504                      }
1505                  }
1506             }
1507             let terrain_char = game.map[position_i]
1508             let terrain_desc = '?'
1509             if (game.terrains[terrain_char]) {
1510                 terrain_desc = game.terrains[terrain_char];
1511             };
1512             info_to_cache += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1513             let protection = game.map_control[position_i];
1514             if (protection == '.') {
1515                 protection = 'unprotected';
1516             };
1517             info_to_cache += 'PROTECTION: ' + protection + '\n';
1518             if (this.position in game.portals) {
1519                 info_to_cache += "PORTAL: " + game.portals[this.position] + "\n";
1520             }
1521             if (this.position in this.annotations) {
1522                 info_to_cache += "ANNOTATION: " + this.annotations[this.position];
1523             }
1524         }
1525         this.info_cached = info_to_cache;
1526         return this.info_cached;
1527     },
1528     get_thing_info: function(t) {
1529         const symbol = game.thing_types[t.type_];
1530         let info = t.type_ + " / " + symbol;
1531         if (t.thing_char) {
1532             info += t.thing_char;
1533         };
1534         if (t.name_) {
1535             info += " (" + t.name_ + ")";
1536         }
1537         if (t.installed) {
1538             info += " / installed";
1539         }
1540         return info;
1541     },
1542     annotate: function(msg) {
1543         if (msg.length == 0) {
1544             msg = " ";  // triggers annotation deletion
1545         }
1546         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1547     },
1548     set_portal: function(msg) {
1549         if (msg.length == 0) {
1550             msg = " ";  // triggers portal deletion
1551         }
1552         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1553     },
1554     send_tile_control_command: function() {
1555         server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1556     }
1557 }
1558
1559 tui.inputEl.addEventListener('input', (event) => {
1560     if (tui.mode.has_input_prompt) {
1561         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1562         if (tui.inputEl.value.length > max_length) {
1563             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1564         };
1565     } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1566         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1567         tui.switch_mode('edit');
1568     }
1569     tui.full_refresh();
1570 }, false);
1571 document.onclick = function() {
1572     if (!tui.mode.is_single_char_entry) {
1573         tui.show_help = false;
1574     }
1575 };
1576 tui.inputEl.addEventListener('keydown', (event) => {
1577     tui.show_help = false;
1578     if (['Enter', 'ArrowLeft', 'ArrowRight'].includes(event.key)) {
1579         event.preventDefault();
1580     }
1581     if ((!tui.mode.is_intro && event.key == 'Escape')
1582         || (tui.mode.has_input_prompt && event.key == 'Enter'
1583             && tui.inputEl.value.length == 0
1584             && ['chat', 'command_thing', 'take_thing', 'drop_thing',
1585                 'admin_enter'].includes(tui.mode.name))) {
1586         if (!['chat', 'play', 'study', 'edit'].includes(tui.mode.name)) {
1587             tui.log_msg('@ aborted');
1588         }
1589         tui.switch_mode('play');
1590     } else if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1591         tui.show_help = true;
1592         tui.inputEl.value = "";
1593         tui.restore_input_values();
1594     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1595                && !tui.mode.is_single_char_entry) {
1596         tui.show_help = true;
1597     } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1598         tui.login_name = tui.inputEl.value;
1599         server.send(['LOGIN', tui.inputEl.value]);
1600         tui.inputEl.value = "";
1601     } else if (tui.mode.name == 'enter_face' && event.key == 'Enter') {
1602         tui.enter_ascii_art('PLAYER_FACE');
1603     } else if (tui.mode.name == 'enter_hat' && event.key == 'Enter') {
1604         tui.enter_ascii_art('PLAYER_HAT');
1605     } else if (tui.mode.name == 'command_thing' && event.key == 'Enter') {
1606         server.send(['TASK:COMMAND', tui.inputEl.value]);
1607         tui.inputEl.value = "";
1608     } else if (tui.mode.name == 'take_thing' && event.key == 'Enter') {
1609         tui.pick_selectable('PICK_UP');
1610     } else if (tui.mode.name == 'drop_thing' && event.key == 'Enter') {
1611         tui.pick_selectable('DROP');
1612     } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1613         if (tui.inputEl.value.length == 0) {
1614             tui.log_msg('@ aborted');
1615         } else {
1616             server.send(['SET_MAP_CONTROL_PASSWORD',
1617                         tui.tile_control_char, tui.inputEl.value]);
1618             tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1619         }
1620         tui.switch_mode('admin');
1621     } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1622         explorer.set_portal(tui.inputEl.value);
1623         tui.switch_mode('edit');
1624     } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1625         if (tui.inputEl.value.length == 0) {
1626             tui.inputEl.value = " ";
1627         }
1628         server.send(["THING_NAME", tui.inputEl.value, tui.password]);
1629         tui.switch_mode('edit');
1630     } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1631         explorer.annotate(tui.inputEl.value);
1632         tui.switch_mode('edit');
1633     } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1634         if (tui.inputEl.value.length == 0) {
1635             tui.inputEl.value = " ";
1636         }
1637         tui.password = tui.inputEl.value
1638         tui.switch_mode('edit');
1639     } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1640         server.send(['BECOME_ADMIN', tui.inputEl.value]);
1641         tui.switch_mode('play');
1642     } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1643         if (tui.inputEl.value.length != 1) {
1644             tui.log_msg('@ entered non-single-char, therefore aborted');
1645             tui.switch_mode('admin');
1646         } else {
1647             tui.tile_control_char = tui.inputEl.value[0];
1648             tui.switch_mode('control_pw_pw');
1649         }
1650     } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1651         if (tui.inputEl.value.length != 1) {
1652             tui.log_msg('@ entered non-single-char, therefore aborted');
1653             tui.switch_mode('admin');
1654         } else {
1655             tui.tile_control_char = tui.inputEl.value[0];
1656             tui.switch_mode('control_tile_draw');
1657         }
1658     } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1659         if (tui.inputEl.value.length != 1) {
1660             tui.log_msg('@ entered non-single-char, therefore aborted');
1661         } else {
1662             server.send(['THING_PROTECTION', tui.inputEl.value])
1663             tui.log_msg('@ sent new protection character for thing');
1664         }
1665         tui.switch_mode('admin');
1666     } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1667         let tokens = parser.tokenize(tui.inputEl.value);
1668         if (tokens.length > 0 && tokens[0].length > 0) {
1669             if (tui.inputEl.value[0][0] == '/') {
1670                 if (tokens[0].slice(1) == 'nick') {
1671                     if (tokens.length > 1) {
1672                         server.send(['NICK', tokens[1]]);
1673                     } else {
1674                         tui.log_msg('? need new name');
1675                     }
1676                 } else {
1677                     tui.log_msg('? unknown command');
1678                 }
1679             } else {
1680                     server.send(['ALL', tui.inputEl.value]);
1681             }
1682         } else if (tui.inputEl.valuelength > 0) {
1683                 server.send(['ALL', tui.inputEl.value]);
1684         }
1685         tui.inputEl.value = "";
1686     } else if (tui.mode.name == 'play') {
1687           if (tui.mode.mode_switch_on_key(event)) {
1688               null;
1689           } else if (event.key === tui.keys.consume && tui.task_action_on('consume')) {
1690               server.send(["TASK:INTOXICATE"]);
1691           } else if (event.key === tui.keys.door && tui.task_action_on('door')) {
1692               server.send(["TASK:DOOR"]);
1693           } else if (event.key === tui.keys.wear && tui.task_action_on('wear')) {
1694               server.send(["TASK:WEAR"]);
1695           } else if (event.key === tui.keys.spin && tui.task_action_on('spin')) {
1696               server.send(["TASK:SPIN"]);
1697           } else if (event.key === tui.keys.dance && tui.task_action_on('dance')) {
1698               server.send(["TASK:DANCE"]);
1699           } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1700               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1701           } else if (event.key === tui.keys.teleport) {
1702               game.teleport();
1703           };
1704     } else if (tui.mode.name == 'study') {
1705         if (tui.mode.mode_switch_on_key(event)) {
1706               null;
1707         } else if (event.key in tui.movement_keys) {
1708             explorer.move(tui.movement_keys[event.key]);
1709         } else if (event.key == tui.keys.toggle_map_mode) {
1710             tui.toggle_map_mode();
1711         };
1712     } else if (tui.mode.name == 'control_tile_draw') {
1713         if (tui.mode.mode_switch_on_key(event)) {
1714             null;
1715         } else if (event.key in tui.movement_keys) {
1716             explorer.move(tui.movement_keys[event.key]);
1717         } else if (event.key === tui.keys.toggle_tile_draw) {
1718             tui.toggle_tile_draw();
1719         };
1720     } else if (tui.mode.name == 'admin') {
1721         if (tui.mode.mode_switch_on_key(event)) {
1722               null;
1723         } else if (event.key == tui.keys.toggle_map_mode) {
1724             tui.toggle_map_mode();
1725         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1726             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1727         };
1728     } else if (tui.mode.name == 'edit') {
1729         if (tui.mode.mode_switch_on_key(event)) {
1730               null;
1731         } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1732             server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1733         } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1734             server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1735           } else if (event.key === tui.keys.install && tui.task_action_on('install')) {
1736               server.send(["TASK:INSTALL", tui.password]);
1737         } else if (event.key == tui.keys.toggle_map_mode) {
1738             tui.toggle_map_mode();
1739         }
1740     }
1741     tui.full_refresh();
1742 }, false);
1743
1744 rows_selector.addEventListener('input', function() {
1745     if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1746         return;
1747     }
1748     window.localStorage.setItem(rows_selector.id, rows_selector.value);
1749     terminal.initialize();
1750     tui.full_refresh();
1751 }, false);
1752 cols_selector.addEventListener('input', function() {
1753     if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1754         return;
1755     }
1756     window.localStorage.setItem(cols_selector.id, cols_selector.value);
1757     terminal.initialize();
1758     tui.window_width = terminal.cols / 2,
1759     tui.full_refresh();
1760 }, false);
1761 for (let key_selector of key_selectors) {
1762     key_selector.addEventListener('input', function() {
1763         window.localStorage.setItem(key_selector.id, key_selector.value);
1764         tui.init_keys();
1765     }, false);
1766 }
1767 window.setInterval(function() {
1768     if (server.websocket.readyState == 1) {
1769         server.send(['PING']);
1770     } else if (server.websocket.readyState != 0) {
1771         server.reconnect_to(server.url);
1772         tui.log_msg('@ attempting reconnect …')
1773     }
1774 }, 1000);
1775 window.setInterval(function() {
1776     if (document.activeElement.tagName.toLowerCase() != 'input') {
1777         const scroll_x = window.scrollX;
1778         const scroll_y = window.scrollY;
1779         tui.inputEl.focus();
1780         window.scrollTo(scroll_x, scroll_y);
1781     };
1782 }, 100);
1783 document.getElementById("help").onclick = function() {
1784     tui.show_help = true;
1785     tui.full_refresh();
1786 };
1787 for (const switchEl  of document.querySelectorAll('[id^="switch_to_"]')) {
1788     const mode = switchEl.id.slice("switch_to_".length);
1789     switchEl.onclick = function() {
1790         tui.switch_mode(mode);
1791         tui.full_refresh();
1792     }
1793 };
1794 document.getElementById("toggle_tile_draw").onclick = function() {
1795     tui.toggle_tile_draw();
1796 }
1797 document.getElementById("toggle_map_mode").onclick = function() {
1798     tui.toggle_map_mode();
1799     tui.full_refresh();
1800 };
1801 document.getElementById("flatten").onclick = function() {
1802     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1803 };
1804 document.getElementById("door").onclick = function() {
1805     server.send(['TASK:DOOR']);
1806 };
1807 document.getElementById("consume").onclick = function() {
1808     server.send(['TASK:INTOXICATE']);
1809 };
1810 document.getElementById("install").onclick = function() {
1811     server.send(['TASK:INSTALL', tui.password]);
1812 };
1813 document.getElementById("wear").onclick = function() {
1814     server.send(['TASK:WEAR']);
1815 };
1816 document.getElementById("spin").onclick = function() {
1817     server.send(['TASK:SPIN']);
1818 };
1819 document.getElementById("dance").onclick = function() {
1820     server.send(['TASK:DANCE']);
1821 };
1822 document.getElementById("teleport").onclick = function() {
1823     game.teleport();
1824 };
1825 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1826     if (move_button.id.startsWith('key_')) {  // not a move button
1827         continue;
1828     };
1829     let direction = move_button.id.split('_')[2].toUpperCase();
1830     let move_repeat;
1831     function move() {
1832         if (tui.mode.available_actions.includes("move")) {
1833             server.send(['TASK:MOVE', direction]);
1834         } else if (tui.mode.available_actions.includes("move_explorer")) {
1835             explorer.move(direction);
1836             tui.full_refresh();
1837         };
1838     }
1839     move_button.onmousedown = function() {
1840         move();
1841         move_repeat = window.setInterval(move, 100);
1842     };
1843     move_button.onmouseup = function() {
1844         window.clearInterval(move_repeat);
1845     }
1846 };
1847 </script>
1848 </body></html>