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