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