home · contact · privacy
Improve player error messages, and log them to clients.
[plomrogue2] / rogue_chat_nocanvas_monochrome.html
1 <!DOCTYPE html>
2 <html><head>
3 <style>
4 </style>
5 </head><body>
6 <div>
7 terminal rows: <input id="n_rows" type="number" step=4 min=8 value=24 />
8 terminal columns: <input id="n_cols" type="number" step=4 min=20 value=80 />
9 </div>
10 <pre id="terminal" style="display: inline-block;"></pre>
11 <textarea id="input" style="opacity: 0; width: 0px;"></textarea>
12 <div>
13 <h3>for mouse players</h3>
14 <table style="float: left">
15 <tr><td><button id="move_upleft">up-left</button></td><td><button id="move_up">up</button></td><td><button id="move_upright">up-right</button></td></tr>
16 <tr><td><button id="move_left">left</button></td><td>MOVE</td><td><button id="move_right">right</button></td></tr>
17 <tr><td><button id="move_downleft">down-left</button></td><td><button id="move_down">down</button></td><td><button id="move_downright">down-right</button></td></tr>
18 </table>
19 <div>
20 <button id="help">help</button>
21 <button id="switch_to_play">play mode</button>
22 <button id="switch_to_study">study mode</button>
23 <button id="switch_to_chat">chat mode</button><br />
24 <button id="take_thing">take thing</button>
25 <button id="drop_thing">drop thing</button>
26 <button id="flatten">flatten surroundings</button>
27 <button id="teleport">teleport</button>
28 <button id="switch_to_edit">change tile</button><br />
29 <button id="switch_to_password">change tile editing password</button>
30 <button id="switch_to_annotate">annotate tile</button>
31 <button id="switch_to_portal">edit portal link</button>
32 <button id="toggle_map_mode">toggle terrain/control view</button>
33 </div>
34 <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 />
35 <ul>
36 <li>move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)
37 <li>move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)
38 <li>move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)
39 <li>move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)
40 <li>move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" />
41 <li>move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" />
42 <li>move right (hex grid): <input id="key_hex_move_right" type="text" value="d" />
43 <li>move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" />
44 <li>move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" />
45 <li>move left (hex grid): <input id="key_hex_move_left" type="text" value="a" />
46 <li>help: <input id="key_help" type="text" value="h" />
47 <li>flatten surroundings: <input id="key_flatten" type="text" value="F" />
48 <li>teleport: <input id="key_teleport" type="text" value="p" />
49 <li>take thing under player: <input id="key_take_thing" type="text" value="z" />
50 <li>drop carried thing: <input id="key_drop_thing" type="text" value="u" />
51 <li>switch to chat mode: <input id="key_switch_to_chat" type="text" value="t" />
52 <li>switch to play mode: <input id="key_switch_to_play" type="text" value="p" />
53 <li>switch to study mode: <input id="key_switch_to_study" type="text" value="?" />
54 <li>edit tile (from play mode): <input id="key_switch_to_edit" type="text" value="m" />
55 <li>enter tile password (from play mode): <input id="key_switch_to_password" type="text" value="P" />
56 <li>annotate tile (from play mode): <input id="key_switch_to_annotate" type="text" value="M" />
57 <li>annotate portal (from play mode): <input id="key_switch_to_portal" type="text" value="T" />
58 <li>toggle terrain/control view (from study mode): <input id="key_toggle_map_mode" type="text" value="M" />
59 </ul>
60 </div>
61 <script>
62 "use strict";
63 let websocket_location = "wss://plomlompom.com/rogue_chat/";
64
65 let rows_selector = document.getElementById("n_rows");
66 let cols_selector = document.getElementById("n_cols");
67 let key_selectors = document.querySelectorAll('[id^="key_"]');
68
69 function restore_selector_value(selector) {
70     let stored_selection = window.localStorage.getItem(selector.id);
71     if (stored_selection) {
72         selector.value = stored_selection;
73     }
74 }
75 restore_selector_value(rows_selector);
76 restore_selector_value(cols_selector);
77 for (let key_selector of key_selectors) {
78     restore_selector_value(key_selector);
79 }
80
81 let terminal = {
82   foreground: 'white',
83   background: 'black',
84   initialize: function() {
85     this.rows = rows_selector.value;
86     this.cols = cols_selector.value;
87     this.pre_el = document.getElementById("terminal");
88     this.pre_el.style.color = this.foreground;
89     this.pre_el.style.backgroundColor = this.background;
90     this.content = [];
91       let line = []
92     for (let y = 0, x = 0; y <= this.rows; x++) {
93         if (x == this.cols) {
94             x = 0;
95             y += 1;
96             this.content.push(line);
97             line = [];
98             if (y == this.rows) {
99                 break;
100             }
101         }
102         line.push(' ');
103     }
104   },
105   blink_screen: function() {
106       this.pre_el.style.color = this.background;
107       this.pre_el.style.backgroundColor = this.foreground;
108       setTimeout(() => {
109           this.pre_el.style.color = this.foreground;
110           this.pre_el.style.backgroundColor = this.background;
111       }, 100);
112   },
113   refresh: function() {
114       let pre_string = '';
115       for (let y = 0; y < this.rows; y++) {
116           let line = this.content[y].join('');
117           pre_string += line + '\n';
118       }
119       this.pre_el.textContent = pre_string;
120   },
121   write: function(start_y, start_x, msg) {
122       for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
123           this.content[start_y][x] = msg[i];
124       }
125   },
126   drawBox: function(start_y, start_x, height, width) {
127     let end_y = start_y + height;
128     let end_x = start_x + width;
129     for (let y = start_y, x = start_x; y < this.rows; x++) {
130       if (x == end_x) {
131         x = start_x;
132         y += 1;
133         if (y == end_y) {
134             break;
135         }
136       };
137       this.content[y][x] = ' ';
138     }
139   },
140 }
141 terminal.initialize();
142
143 let parser = {
144   tokenize: function(str) {
145     let tokens = [];
146     let token = ''
147     let quoted = false;
148     let escaped = false;
149     for (let i = 0; i < str.length; i++) {
150       let c = str[i];
151       if (quoted) {
152         if (escaped) {
153           token += c;
154           escaped = false;
155         } else if (c == '\\') {
156           escaped = true;
157         } else if (c == '"') {
158           quoted = false
159         } else {
160           token += c;
161         }
162       } else if (c == '"') {
163         quoted = true
164       } else if (c === ' ') {
165         if (token.length > 0) {
166           tokens.push(token);
167           token = '';
168         }
169       } else {
170         token += c;
171       }
172     }
173     if (token.length > 0) {
174       tokens.push(token);
175     }
176     return tokens;
177   },
178   parse_yx: function(position_string) {
179     let coordinate_strings = position_string.split(',')
180     let position = [0, 0];
181     position[0] = parseInt(coordinate_strings[0].slice(2));
182     position[1] = parseInt(coordinate_strings[1].slice(2));
183     return position;
184   },
185 }
186
187 class Thing {
188     constructor(yx) {
189         this.position = yx;
190     }
191 }
192
193 let server = {
194     init: function(url) {
195         this.url = url;
196         this.websocket = new WebSocket(this.url);
197         this.websocket.onopen = function(event) {
198             server.connected = true;
199             game.thing_types = {};
200             game.terrains = {};
201             server.send(['TASKS']);
202             server.send(['TERRAINS']);
203             server.send(['THING_TYPES']);
204             tui.log_msg("@ server connected! :)");
205             tui.switch_mode(mode_login);
206         };
207         this.websocket.onclose = function(event) {
208             server.connected = false;
209             tui.switch_mode(mode_waiting_for_server);
210             tui.log_msg("@ server disconnected :(");
211         };
212             this.websocket.onmessage = this.handle_event;
213         },
214     reconnect_to: function(url) {
215         this.websocket.close();
216         this.init(url);
217     },
218     send: function(tokens) {
219         this.websocket.send(unparser.untokenize(tokens));
220     },
221     handle_event: function(event) {
222         let tokens = parser.tokenize(event.data);
223         if (tokens[0] === 'TURN') {
224             game.turn_complete = false;
225             game.things = {};
226             game.portals = {};
227             game.turn = parseInt(tokens[1]);
228         } else if (tokens[0] === 'THING') {
229             let t = game.get_thing(tokens[3], true);
230             t.position = parser.parse_yx(tokens[1]);
231             t.type_ = tokens[2];
232         } else if (tokens[0] === 'THING_NAME') {
233             let t = game.get_thing(tokens[1], false);
234             if (t) {
235                 t.name_ = tokens[2];
236             };
237         } else if (tokens[0] === 'THING_CHAR') {
238             let t = game.get_thing(tokens[1], false);
239             if (t) {
240                 t.player_char = tokens[2];
241             };
242         } else if (tokens[0] === 'TASKS') {
243             game.tasks = tokens[1].split(',')
244         } else if (tokens[0] === 'THING_TYPE') {
245             game.thing_types[tokens[1]] = tokens[2]
246         } else if (tokens[0] === 'TERRAIN') {
247             game.terrains[tokens[1]] = tokens[2]
248         } else if (tokens[0] === 'MAP') {
249             game.map_geometry = tokens[1];
250             tui.init_keys();
251             game.map_size = parser.parse_yx(tokens[2]);
252             game.map = tokens[3]
253         } else if (tokens[0] === 'FOV') {
254             game.fov = tokens[1]
255         } else if (tokens[0] === 'MAP_CONTROL') {
256             game.map_control = tokens[1]
257         } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
258             game.turn_complete = true;
259             explorer.empty_info_db();
260             if (tui.mode == mode_post_login_wait) {
261                 tui.switch_mode(mode_play);
262             } else if (tui.mode == mode_study) {
263                 explorer.query_info();
264             }
265             tui.full_refresh();
266         } else if (tokens[0] === 'CHAT') {
267              tui.log_msg('# ' + tokens[1], 1);
268         } else if (tokens[0] === 'PLAYER_ID') {
269             game.player_id = parseInt(tokens[1]);
270         } else if (tokens[0] === 'LOGIN_OK') {
271             this.send(['GET_GAMESTATE']);
272             tui.switch_mode(mode_post_login_wait);
273         } else if (tokens[0] === 'PORTAL') {
274             let position = parser.parse_yx(tokens[1]);
275             game.portals[position] = tokens[2];
276         } else if (tokens[0] === 'ANNOTATION') {
277             let position = parser.parse_yx(tokens[1]);
278             explorer.update_info_db(position, tokens[2]);
279         } else if (tokens[0] === 'UNHANDLED_INPUT') {
280             tui.log_msg('? unknown command');
281         } else if (tokens[0] === 'PLAY_ERROR') {
282             tui.log_msg('? ' + tokens[1]);
283             terminal.blink_screen();
284         } else if (tokens[0] === 'ARGUMENT_ERROR') {
285             tui.log_msg('? syntax error: ' + tokens[1]);
286         } else if (tokens[0] === 'GAME_ERROR') {
287             tui.log_msg('? game error: ' + tokens[1]);
288         } else if (tokens[0] === 'PONG') {
289             ;
290         } else {
291             tui.log_msg('? unhandled input: ' + event.data);
292         }
293     }
294 }
295
296 let unparser = {
297     quote: function(str) {
298         let quoted = ['"'];
299         for (let i = 0; i < str.length; i++) {
300             let c = str[i];
301             if (['"', '\\'].includes(c)) {
302                 quoted.push('\\');
303             };
304             quoted.push(c);
305         }
306         quoted.push('"');
307         return quoted.join('');
308     },
309     to_yx: function(yx_coordinate) {
310         return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
311     },
312     untokenize: function(tokens) {
313         let quoted_tokens = [];
314         for (let token of tokens) {
315             quoted_tokens.push(this.quote(token));
316         }
317         return quoted_tokens.join(" ");
318     }
319 }
320
321 class Mode {
322     constructor(name, help_intro, has_input_prompt=false, shows_info=false, is_intro=false) {
323         this.name = name;
324         this.has_input_prompt = has_input_prompt;
325         this.shows_info= shows_info;
326         this.is_intro = is_intro;
327         this.help_intro = help_intro;
328     }
329 }
330 let mode_waiting_for_server = new Mode('waiting_for_server', 'Waiting for a server response.', false, false, true);
331 let mode_login = new Mode('login', 'Pick your player name.', true, false, true);
332 let mode_post_login_wait = new Mode('waiting for game world', 'Waiting for a server response.', false, false, true);
333 let mode_chat = new Mode('chat', '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:', true, false);
334   let mode_annotate = new Mode('annotate', 'This mode allows you to add/edit a comment on the tile you are currently standing on (provided your map editing password authorizes you so).  Hit Return to leave.', true, true);
335 let mode_play = new Mode('play', 'This mode allows you to interact with the map.', false, false);
336 let mode_study = new Mode('study', '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.', false, true);
337 let mode_edit = new Mode('edit', 'This mode allows you to change the map tile you currently stand on (if your map editing password authorizes you so).  Just enter any printable ASCII character to imprint it on the ground below you.', false, false);
338 let mode_portal = new Mode('portal', 'This mode allows you to imprint/edit/remove a teleportation target on the ground you are currently standing on (provided your map 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.', true, true);
339 let mode_password = new Mode('password', 'This mode allows you to change the password that you send to authorize yourself for editing password-protected map tiles.  Hit return to confirm and leave.', true, false, false);
340
341 let tui = {
342   mode: mode_waiting_for_server,
343   log: [],
344   input_prompt: '> ',
345   input_lines: [],
346   window_width: terminal.cols / 2,
347   height_turn_line: 1,
348   height_mode_line: 1,
349   height_input: 1,
350   password: 'foo',
351   show_help: false,
352   init: function() {
353       this.inputEl = document.getElementById("input");
354       this.inputEl.focus();
355       this.recalc_input_lines();
356       this.height_header = this.height_turn_line + this.height_mode_line;
357       this.log_msg("@ waiting for server connection ...");
358       this.init_keys();
359   },
360   init_keys: function() {
361     this.keys = {};
362     for (let key_selector of key_selectors) {
363         this.keys[key_selector.id.slice(4)] = key_selector.value;
364     }
365     this.movement_keys = {
366         [this.keys.square_move_up]: 'UP',
367         [this.keys.square_move_left]: 'LEFT',
368         [this.keys.square_move_down]: 'DOWN',
369         [this.keys.square_move_right]: 'RIGHT'
370     };
371     if (game.map_geometry == 'Hex') {
372         this.movement_keys = {
373             [this.keys.hex_move_upleft]: 'UPLEFT',
374             [this.keys.hex_move_upright]: 'UPRIGHT',
375             [this.keys.hex_move_right]: 'RIGHT',
376             [this.keys.hex_move_downright]: 'DOWNRIGHT',
377             [this.keys.hex_move_downleft]: 'DOWNLEFT',
378             [this.keys.hex_move_left]: 'LEFT'
379         };
380     };
381   },
382   switch_mode: function(mode) {
383     this.inputEl.focus();
384     this.show_help = false;
385     this.map_mode = 'terrain';
386     if (mode.shows_info && game.player_id in game.things) {
387       explorer.position = game.things[game.player_id].position;
388     }
389     this.mode = mode;
390     this.empty_input();
391     this.restore_input_values();
392     document.getElementById("take_thing").disabled = true;
393     document.getElementById("drop_thing").disabled = true;
394     document.getElementById("flatten").disabled = true;
395     document.getElementById("teleport").disabled = true;
396     document.getElementById("toggle_map_mode").disabled = true;
397     document.getElementById("switch_to_chat").disabled = true;
398     document.getElementById("switch_to_play").disabled = true;
399     document.getElementById("switch_to_study").disabled = true;
400     document.getElementById("switch_to_edit").disabled = true;
401     document.getElementById("switch_to_portal").disabled = true;
402     document.getElementById("switch_to_annotate").disabled = true;
403     document.getElementById("switch_to_password").disabled = true;
404     document.getElementById("move_left").disabled = true;
405     document.getElementById("move_upleft").disabled = true;
406     document.getElementById("move_up").disabled = true;
407     document.getElementById("move_upright").disabled = true;
408     document.getElementById("move_downleft").disabled = true;
409     document.getElementById("move_down").disabled = true;
410     document.getElementById("move_downright").disabled = true;
411     document.getElementById("move_right").disabled = true;
412     if (mode == mode_play || mode == mode_study) {
413         document.getElementById("move_left").disabled = false;
414         document.getElementById("move_right").disabled = false;
415         if (game.map_geometry == 'Hex') {
416             document.getElementById("move_upleft").disabled = false;
417             document.getElementById("move_upright").disabled = false;
418             document.getElementById("move_downleft").disabled = false;
419             document.getElementById("move_downright").disabled = false;
420         } else {
421             document.getElementById("move_up").disabled = false;
422             document.getElementById("move_down").disabled = false;
423         }
424     }
425     if (!mode.is_intro && mode != mode_play) {
426         document.getElementById("switch_to_play").disabled = false;
427     }
428     if (!mode.is_intro && mode != mode_study) {
429         document.getElementById("switch_to_study").disabled = false;
430     }
431     if (!mode.is_intro && mode != mode_chat) {
432         document.getElementById("switch_to_chat").disabled = false;
433     }
434     if (mode == mode_login) {
435         if (this.login_name) {
436             server.send(['LOGIN', this.login_name]);
437         } else {
438             this.log_msg("? need login name");
439         }
440     } else if (mode == mode_play) {
441         if (game.tasks.includes('PICK_UP')) {
442             document.getElementById("take_thing").disabled = false;
443         }
444         if (game.tasks.includes('DROP')) {
445             document.getElementById("drop_thing").disabled = false;
446         }
447         if (game.tasks.includes('FLATTEN_SURROUNDINGS')) {
448             document.getElementById("flatten").disabled = false;
449         }
450         if (game.tasks.includes('MOVE')) {
451         }
452         document.getElementById("teleport").disabled = false;
453         document.getElementById("switch_to_annotate").disabled = false;
454         document.getElementById("switch_to_edit").disabled = false;
455         document.getElementById("switch_to_portal").disabled = false;
456         document.getElementById("switch_to_password").disabled = false;
457     } else if (mode == mode_study) {
458         document.getElementById("toggle_map_mode").disabled = false;
459     } else if (mode == mode_edit) {
460         this.show_help = true;
461     }
462     this.full_refresh();
463   },
464   restore_input_values: function() {
465       if (this.mode == mode_annotate && explorer.position in explorer.info_db) {
466           let info = explorer.info_db[explorer.position];
467           if (info != "(none)") {
468               this.inputEl.value = info;
469               this.recalc_input_lines();
470           }
471       } else if (this.mode == mode_portal && explorer.position in game.portals) {
472           let portal = game.portals[explorer.position]
473           this.inputEl.value = portal;
474           this.recalc_input_lines();
475       } else if (this.mode == mode_password) {
476           this.inputEl.value = this.password;
477           this.recalc_input_lines();
478       }
479   },
480   empty_input: function(str) {
481       this.inputEl.value = "";
482       if (this.mode.has_input_prompt) {
483           this.recalc_input_lines();
484       } else {
485           this.height_input = 0;
486       }
487   },
488   recalc_input_lines: function() {
489       this.input_lines = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width);
490       this.height_input = this.input_lines.length;
491   },
492   msg_into_lines_of_width: function(msg, width) {
493     let chunk = "";
494     let lines = [];
495     for (let i = 0, x = 0; i < msg.length; i++, x++) {
496       if (x >= width || msg[i] == "\n") {
497         lines.push(chunk);
498         chunk = "";
499         x = 0;
500       };
501       if (msg[i] != "\n") {
502         chunk += msg[i];
503       }
504     }
505     lines.push(chunk);
506     return lines;
507   },
508   log_msg: function(msg) {
509       this.log.push(msg);
510       while (this.log.length > 100) {
511         this.log.shift();
512       };
513       this.full_refresh();
514   },
515   draw_map: function() {
516     let map_lines_split = [];
517     let line = [];
518     let map_content = game.map;
519     if (this.map_mode == 'control') {
520         map_content = game.map_control;
521     }
522     for (let i = 0, j = 0; i < game.map.length; i++, j++) {
523         if (j == game.map_size[1]) {
524             map_lines_split.push(line);
525             line = [];
526             j = 0;
527         };
528         line.push(map_content[i] + ' ');
529     };
530     map_lines_split.push(line);
531     if (this.map_mode == 'terrain') {
532         for (const p in game.portals) {
533             let coordinate = p.split(',')
534             map_lines_split[coordinate[0]][coordinate[1]] = 'P ';
535         }
536         let used_positions = [];
537         for (const thing_id in game.things) {
538             let t = game.things[thing_id];
539             let symbol = game.thing_types[t.type_];
540             let meta_char = ' ';
541             if (t.player_char) {
542                 meta_char = t.player_char;
543             }
544             if (used_positions.includes(t.position.toString())) {
545                 meta_char = '+';
546             };
547             map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
548             used_positions.push(t.position.toString());
549         };
550     }
551     if (tui.mode.shows_info) {
552         map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
553     }
554     let map_lines = []
555     if (game.map_geometry == 'Square') {
556         for (let line_split of map_lines_split) {
557             map_lines.push(line_split.join(''));
558         };
559     } else if (game.map_geometry == 'Hex') {
560         let indent = 0
561         for (let line_split of map_lines_split) {
562             map_lines.push(' '.repeat(indent) + line_split.join(''));
563             if (indent == 0) {
564                 indent = 1;
565             } else {
566                 indent = 0;
567             };
568         };
569     }
570     let window_center = [terminal.rows / 2, this.window_width / 2];
571     let player = game.things[game.player_id];
572     let center_position = [player.position[0], player.position[1]];
573     if (tui.mode.shows_info) {
574         center_position = [explorer.position[0], explorer.position[1]];
575     }
576     center_position[1] = center_position[1] * 2;
577     let offset = [center_position[0] - window_center[0],
578                   center_position[1] - window_center[1]]
579     if (game.map_geometry == 'Hex' && offset[0] % 2) {
580         offset[1] += 1;
581     };
582     let term_y = Math.max(0, -offset[0]);
583     let term_x = Math.max(0, -offset[1]);
584     let map_y = Math.max(0, offset[0]);
585     let map_x = Math.max(0, offset[1]);
586     for (; term_y < terminal.rows && map_y < game.map_size[0]; term_y++, map_y++) {
587         let to_draw = map_lines[map_y].slice(map_x, this.window_width + offset[1]);
588         terminal.write(term_y, term_x, to_draw);
589     }
590   },
591   draw_mode_line: function() {
592       let help = 'hit [' + this.keys.help + '] for help';
593       if (this.mode.has_input_prompt) {
594           help = 'enter /help for help';
595       }
596       terminal.write(0, this.window_width, 'MODE: ' + this.mode.name + ' – ' + help);
597   },
598   draw_turn_line: function(n) {
599     terminal.write(1, this.window_width, 'TURN: ' + game.turn);
600   },
601   draw_history: function() {
602       let log_display_lines = [];
603       for (let line of this.log) {
604           log_display_lines = log_display_lines.concat(this.msg_into_lines_of_width(line, this.window_width));
605       };
606       for (let y = terminal.rows - 1 - this.height_input,
607                i = log_display_lines.length - 1;
608            y >= this.height_header && i >= 0;
609            y--, i--) {
610           terminal.write(y, this.window_width, log_display_lines[i]);
611       }
612   },
613   draw_info: function() {
614     let lines = this.msg_into_lines_of_width(explorer.get_info(), this.window_width);
615     for (let y = this.height_header, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
616       terminal.write(y, this.window_width, lines[i]);
617     }
618   },
619   draw_input: function() {
620     if (this.mode.has_input_prompt) {
621         for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
622             terminal.write(y, this.window_width, this.input_lines[i]);
623         }
624     }
625   },
626   draw_help: function() {
627       let movement_keys_desc = Object.keys(this.movement_keys).join(',');
628       let content = this.mode.name + " mode help\n\n" + this.mode.help_intro + "\n\n";
629       if (this.mode == mode_play) {
630           content += "Available actions:\n";
631           if (game.tasks.includes('MOVE')) {
632               content += "[" + movement_keys_desc + "] – move player\n";
633           }
634           if (game.tasks.includes('PICK_UP')) {
635               content += "[" + this.keys.take_thing + "] – take thing under player\n";
636           }
637           if (game.tasks.includes('DROP')) {
638               content += "[" + this.keys.drop_thing + "] – drop carried thing\n";
639           }
640           if (game.tasks.includes('FLATTEN_SURROUNDINGS')) {
641               content += "[" + tui.keys.flatten + "] – flatten player's surroundings\n";
642           }
643           content += "[" + tui.keys.teleport + "] – teleport to other space\n";
644           content += '\nOther modes available from here:\n';
645           content += '[' + this.keys.switch_to_chat + '] – chat mode\n';
646           content += '[' + this.keys.switch_to_study + '] – study mode\n';
647           content += '[' + this.keys.switch_to_edit + '] – terrain edit mode\n';
648           content += '[' + this.keys.switch_to_portal + '] – portal edit mode\n';
649           content += '[' + this.keys.switch_to_annotate + '] – annotation mode\n';
650           content += '[' + this.keys.switch_to_password + '] – password input mode\n';
651       } else if (this.mode == mode_study) {
652           content += "Available actions:\n";
653           content += '[' + movement_keys_desc + '] – move question mark\n';
654           content += '[' + this.keys.toggle_map_mode + '] – toggle view between terrain, and password protection areas\n';
655           content += '\nOther modes available from here:\n';
656           content += '[' + this.keys.switch_to_chat + '] – chat mode\n';
657           content += '[' + this.keys.switch_to_play + '] – play mode\n';
658       } else if (this.mode == mode_chat) {
659           content += '/nick NAME – re-name yourself to NAME\n';
660           content += '/' + this.keys.switch_to_play + ' or /play – switch to play mode\n';
661           content += '/' + this.keys.switch_to_study + ' or /study – switch to study mode\n';
662       }
663       let start_x = 0;
664       if (!this.mode.has_input_prompt) {
665           start_x = this.window_width
666       }
667       terminal.drawBox(0, start_x, terminal.rows, this.window_width);
668       let lines = this.msg_into_lines_of_width(content, this.window_width);
669       for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
670           terminal.write(y, start_x, lines[i]);
671       }
672   },
673   full_refresh: function() {
674     terminal.drawBox(0, 0, terminal.rows, terminal.cols);
675     if (this.mode.is_intro) {
676         this.draw_history();
677         this.draw_input();
678     } else {
679         if (game.turn_complete) {
680             this.draw_map();
681             this.draw_turn_line();
682         }
683         this.draw_mode_line();
684         if (this.mode.shows_info) {
685           this.draw_info();
686         } else {
687           this.draw_history();
688         }
689         this.draw_input();
690     }
691     if (this.show_help) {
692         this.draw_help();
693     }
694     terminal.refresh();
695   }
696 }
697
698 let game = {
699     init: function() {
700         this.things = {};
701         this.turn = -1;
702         this.map = "";
703         this.map_control = "";
704         this.map_size = [0,0];
705         this.player_id = -1;
706         this.portals = {};
707         this.tasks = {};
708     },
709     get_thing: function(id_, create_if_not_found=false) {
710         if (id_ in game.things) {
711             return game.things[id_];
712         } else if (create_if_not_found) {
713             let t = new Thing([0,0]);
714             game.things[id_] = t;
715             return t;
716         };
717     },
718     move: function(start_position, direction) {
719         let target = [start_position[0], start_position[1]];
720         if (direction == 'LEFT') {
721             target[1] -= 1;
722         } else if (direction == 'RIGHT') {
723             target[1] += 1;
724         } else if (game.map_geometry == 'Square') {
725             if (direction == 'UP') {
726                 target[0] -= 1;
727             } else if (direction == 'DOWN') {
728                 target[0] += 1;
729             };
730         } else if (game.map_geometry == 'Hex') {
731             let start_indented = start_position[0] % 2;
732             if (direction == 'UPLEFT') {
733                 target[0] -= 1;
734                 if (!start_indented) {
735                     target[1] -= 1;
736                 }
737             } else if (direction == 'UPRIGHT') {
738                 target[0] -= 1;
739                 if (start_indented) {
740                     target[1] += 1;
741                 }
742             } else if (direction == 'DOWNLEFT') {
743                 target[0] += 1;
744                 if (!start_indented) {
745                     target[1] -= 1;
746                 }
747             } else if (direction == 'DOWNRIGHT') {
748                 target[0] += 1;
749                 if (start_indented) {
750                     target[1] += 1;
751                 }
752             };
753         };
754         if (target[0] < 0 || target[1] < 0 ||
755             target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
756             return null;
757         };
758         return target;
759     },
760     teleport: function() {
761         let player = this.get_thing(game.player_id);
762         if (player.position in this.portals) {
763             server.reconnect_to(this.portals[player.position]);
764         } else {
765             terminal.blink_screen();
766             tui.log_msg('? not standing on portal')
767         }
768     }
769 }
770
771 game.init();
772 tui.init();
773 tui.full_refresh();
774 server.init(websocket_location);
775
776 let explorer = {
777     position: [0,0],
778     info_db: {},
779     move: function(direction) {
780         let target = game.move(this.position, direction);
781         if (target) {
782             this.position = target
783             this.query_info();
784         } else {
785             terminal.blink_screen();
786         };
787     },
788     update_info_db: function(yx, str) {
789         this.info_db[yx] = str;
790         if (tui.mode == mode_study) {
791             tui.full_refresh();
792         }
793     },
794     empty_info_db: function() {
795         this.info_db = {};
796         if (tui.mode == mode_study) {
797             tui.full_refresh();
798         }
799     },
800     query_info: function() {
801         server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]);
802     },
803     get_info: function() {
804         let position_i = this.position[0] * game.map_size[1] + this.position[1];
805         if (game.fov[position_i] != '.') {
806             return 'outside field of view';
807         };
808         let info = "";
809         let terrain_char = game.map[position_i]
810         let terrain_desc = '?'
811         if (game.terrains[terrain_char]) {
812             terrain_desc = game.terrains[terrain_char];
813         };
814         info += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
815         for (let t_id in game.things) {
816              let t = game.things[t_id];
817              if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
818                  let symbol = game.thing_types[t.type_];
819                  info += "THING: " + t.type_ + " / " + symbol;
820                  if (t.player_char) {
821                      info += t.player_char;
822                  };
823                  if (t.name_) {
824                      info += " (" + t.name_ + ")";
825                  }
826                  info += "\n";
827              }
828         }
829         if (this.position in game.portals) {
830             info += "PORTAL: " + game.portals[this.position] + "\n";
831         }
832         if (this.position in this.info_db) {
833             info += "ANNOTATIONS: " + this.info_db[this.position];
834         } else {
835             info += 'waiting …';
836         }
837         return info;
838     },
839     annotate: function(msg) {
840         if (msg.length == 0) {
841             msg = " ";  // triggers annotation deletion
842         }
843         server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
844     },
845     set_portal: function(msg) {
846         if (msg.length == 0) {
847             msg = " ";  // triggers portal deletion
848         }
849         server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
850     }
851 }
852
853 tui.inputEl.addEventListener('input', (event) => {
854     if (tui.mode.has_input_prompt) {
855         let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
856         if (tui.inputEl.value.length > max_length) {
857             tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
858         };
859         tui.recalc_input_lines();
860     } else if (tui.mode == mode_edit && tui.inputEl.value.length > 0) {
861         server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
862         tui.switch_mode(mode_play);
863     }
864     tui.full_refresh();
865 }, false);
866 tui.inputEl.addEventListener('keydown', (event) => {
867     tui.show_help = false;
868     if (event.key == 'Enter') {
869         event.preventDefault();
870     }
871     if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
872         tui.show_help = true;
873         tui.empty_input();
874         tui.restore_input_values();
875     } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help) {
876         tui.show_help = true;
877     } else if (tui.mode == mode_login && event.key == 'Enter') {
878         tui.login_name = tui.inputEl.value;
879         server.send(['LOGIN', tui.inputEl.value]);
880         tui.empty_input();
881     } else if (tui.mode == mode_portal && event.key == 'Enter') {
882         explorer.set_portal(tui.inputEl.value);
883         tui.switch_mode(mode_play);
884     } else if (tui.mode == mode_annotate && event.key == 'Enter') {
885         explorer.annotate(tui.inputEl.value);
886         tui.switch_mode(mode_play);
887     } else if (tui.mode == mode_password && event.key == 'Enter') {
888         if (tui.inputEl.value.length == 0) {
889             tui.inputEl.value = " ";
890         }
891         tui.password = tui.inputEl.value
892         tui.switch_mode(mode_play);
893     } else if (tui.mode == mode_chat && event.key == 'Enter') {
894         let [tokens, token_starts] = parser.tokenize(tui.inputEl.value);
895         if (tokens.length > 0 && tokens[0].length > 0) {
896             if (tui.inputEl.value[0][0] == '/') {
897                 if (tokens[0].slice(1) == 'play' || tokens[0][1] == tui.keys.switch_to_play) {
898                     tui.switch_mode(mode_play);
899                 } else if (tokens[0].slice(1) == 'study' || tokens[0][1] == tui.keys.switch_to_study) {
900                     tui.switch_mode(mode_study);
901                 } else if (tokens[0].slice(1) == 'nick') {
902                     if (tokens.length > 1) {
903                         server.send(['NICK', tokens[1]]);
904                     } else {
905                         tui.log_msg('? need new name');
906                     }
907                 } else {
908                     tui.log_msg('? unknown command');
909                 }
910             } else {
911                     server.send(['ALL', tui.inputEl.value]);
912             }
913         } else if (tui.inputEl.valuelength > 0) {
914                 server.send(['ALL', tui.inputEl.value]);
915         }
916         tui.empty_input();
917     } else if (tui.mode == mode_play) {
918           if (event.key === tui.keys.switch_to_chat) {
919               event.preventDefault();
920               tui.switch_mode(mode_chat);
921           } else if (event.key === tui.keys.switch_to_edit
922                      && game.tasks.includes('WRITE')) {
923               event.preventDefault();
924               tui.switch_mode(mode_edit);
925           } else if (event.key === tui.keys.switch_to_study) {
926               tui.switch_mode(mode_study);
927           } else if (event.key === tui.keys.switch_to_password) {
928               event.preventDefault();
929               tui.switch_mode(mode_password);
930           } else if (event.key === tui.keys.flatten
931                      && game.tasks.includes('FLATTEN_SURROUNDINGS')) {
932               server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
933           } else if (event.key === tui.keys.take_thing
934                      && game.tasks.includes('PICK_UP')) {
935               server.send(["TASK:PICK_UP"]);
936           } else if (event.key === tui.keys.drop_thing
937                      && game.tasks.includes('DROP')) {
938               server.send(["TASK:DROP"]);
939           } else if (event.key in tui.movement_keys
940                      && game.tasks.includes('MOVE')) {
941               server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
942           } else if (event.key === tui.keys.teleport) {
943               game.teleport();
944           } else if (event.key === tui.keys.switch_to_portal) {
945               event.preventDefault();
946               tui.switch_mode(mode_portal);
947           } else if (event.key === tui.keys.switch_to_annotate) {
948               event.preventDefault();
949               tui.switch_mode(mode_annotate);
950           };
951     } else if (tui.mode == mode_study) {
952         if (event.key === tui.keys.switch_to_chat) {
953             event.preventDefault();
954             tui.switch_mode(mode_chat);
955         } else if (event.key == tui.keys.switch_to_play) {
956             tui.switch_mode(mode_play);
957         } else if (event.key in tui.movement_keys) {
958             explorer.move(tui.movement_keys[event.key]);
959         } else if (event.key == tui.keys.toggle_map_mode) {
960             if (tui.map_mode == 'terrain') {
961                 tui.map_mode = 'control';
962             } else {
963                 tui.map_mode = 'terrain';
964             }
965         };
966     }
967     tui.full_refresh();
968 }, false);
969
970 rows_selector.addEventListener('input', function() {
971     if (rows_selector.value % 4 != 0) {
972         return;
973     }
974     window.localStorage.setItem(rows_selector.id, rows_selector.value);
975     terminal.initialize();
976     tui.full_refresh();
977 }, false);
978 cols_selector.addEventListener('input', function() {
979     if (cols_selector.value % 4 != 0) {
980         return;
981     }
982     window.localStorage.setItem(cols_selector.id, cols_selector.value);
983     terminal.initialize();
984     tui.window_width = terminal.cols / 2,
985     tui.full_refresh();
986 }, false);
987 for (let key_selector of key_selectors) {
988     key_selector.addEventListener('input', function() {
989         window.localStorage.setItem(key_selector.id, key_selector.value);
990         tui.init_keys();
991     }, false);
992 }
993 window.setInterval(function() {
994     if (server.connected) {
995         server.send(['PING']);
996     } else {
997         server.reconnect_to(server.url);
998         tui.log_msg('@ attempting reconnect …')
999     }
1000 }, 5000);
1001 document.getElementById("terminal").onclick = function() {
1002     tui.inputEl.focus();
1003 };
1004 document.getElementById("help").onclick = function() {
1005     tui.show_help = true;
1006     tui.full_refresh();
1007 };
1008 document.getElementById("switch_to_play").onclick = function() {
1009     tui.switch_mode(mode_play);
1010     tui.full_refresh();
1011 };
1012 document.getElementById("switch_to_study").onclick = function() {
1013     tui.switch_mode(mode_study);
1014     tui.full_refresh();
1015 };
1016 document.getElementById("switch_to_chat").onclick = function() {
1017     tui.switch_mode(mode_chat);
1018     tui.full_refresh();
1019 };
1020 document.getElementById("switch_to_password").onclick = function() {
1021     tui.switch_mode(mode_password);
1022     tui.full_refresh();
1023 };
1024 document.getElementById("switch_to_edit").onclick = function() {
1025     tui.switch_mode(mode_edit);
1026     tui.full_refresh();
1027 };
1028 document.getElementById("switch_to_annotate").onclick = function() {
1029     tui.switch_mode(mode_annotate);
1030     tui.full_refresh();
1031 };
1032 document.getElementById("switch_to_portal").onclick = function() {
1033     tui.switch_mode(mode_portal);
1034     tui.full_refresh();
1035 };
1036 document.getElementById("toggle_map_mode").onclick = function() {
1037     if (tui.map_mode == 'terrain') {
1038         tui.map_mode = 'control';
1039     } else {
1040         tui.map_mode = 'terrain';
1041     }
1042     tui.full_refresh();
1043 };
1044 document.getElementById("take_thing").onclick = function() {
1045         server.send(['TASK:PICK_UP']);
1046 };
1047 document.getElementById("drop_thing").onclick = function() {
1048         server.send(['TASK:DROP']);
1049 };
1050 document.getElementById("flatten").onclick = function() {
1051     server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1052 };
1053 document.getElementById("teleport").onclick = function() {
1054     game.teleport();
1055 };
1056 document.getElementById("move_upleft").onclick = function() {
1057     if (tui.mode == mode_play) {
1058         server.send(['TASK:MOVE', 'UPLEFT']);
1059     } else {
1060         explorer.move('UPLEFT');
1061     };
1062 };
1063 document.getElementById("move_left").onclick = function() {
1064     if (tui.mode == mode_play) {
1065         server.send(['TASK:MOVE', 'LEFT']);
1066     } else {
1067         explorer.move('LEFT');
1068     };
1069 };
1070 document.getElementById("move_downleft").onclick = function() {
1071     if (tui.mode == mode_play) {
1072         server.send(['TASK:MOVE', 'DOWNLEFT']);
1073     } else {
1074         explorer.move('DOWNLEFT');
1075     };
1076 };
1077 document.getElementById("move_down").onclick = function() {
1078     if (tui.mode == mode_play) {
1079         server.send(['TASK:MOVE', 'DOWN']);
1080     } else {
1081         explorer.move('DOWN');
1082     };
1083 };
1084 document.getElementById("move_up").onclick = function() {
1085     if (tui.mode == mode_play) {
1086         server.send(['TASK:MOVE', 'UP']);
1087     } else {
1088         explorer.move('UP');
1089     };
1090 };
1091 document.getElementById("move_upright").onclick = function() {
1092     if (tui.mode == mode_play) {
1093         server.send(['TASK:MOVE', 'UPRIGHT']);
1094     } else {
1095         explorer.move('UPRIGHT');
1096     };
1097 };
1098 document.getElementById("move_right").onclick = function() {
1099     if (tui.mode == mode_play) {
1100         server.send(['TASK:MOVE', 'RIGHT']);
1101     } else {
1102         explorer.move('RIGHT');
1103     };
1104 };
1105 document.getElementById("move_downright").onclick = function() {
1106     if (tui.mode == mode_play) {
1107         server.send(['TASK:MOVE', 'DOWNRIGHT']);
1108     } else {
1109         explorer.move('DOWNRIGHT');
1110     };
1111 };
1112 </script>
1113 </body></html>