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