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)
17 <pre id="terminal"></pre>
18 <textarea id="input" style="opacity: 0; width: 0px;"></textarea>
20 keyboard input/control: <span id="keyboard_control"></span>
22 <h3>button controls for mouse players</h3>
23 <table id="move_table" style="float: left">
25 <td style="text-align: right"><button id="hex_move_upleft">up-left</button></td>
26 <td style="text-align: center"><button id="square_move_up">up</button></td>
27 <td><button id="hex_move_upright">up-right</button></td>
30 <td style="text-align: right;"><button id="square_move_left">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">right</button><button id="hex_move_right">right</button></td>
35 <td><button id="hex_move_downleft">down-left</button></td>
36 <td style="text-align: center"><button id="square_move_down">down</button></td>
37 <td><button id="hex_move_downright">down-right</button></td>
42 <td><button id="help">help</button></td>
45 <td><button id="switch_to_chat">chat mode</button><br /></td>
48 <td><button id="switch_to_study">study mode</button></td>
49 <td><button id="toggle_map_mode">toggle map view</button>
52 <td><button id="switch_to_play">play mode</button></td>
54 <button id="take_thing">pick up thing</button>
55 <button id="drop_thing">drop thing</button>
56 <button id="teleport">teleport</button>
60 <td><button id="switch_to_edit">world edit mode</button></td>
62 <button id="switch_to_write">change terrain</button>
63 <button id="flatten">flatten surroundings</button>
64 <button id="switch_to_annotate">annotate tile</button>
65 <button id="switch_to_portal">edit portal</button>
66 <button id="switch_to_name_thing">name thing</button>
67 <button id="switch_to_password">enter world edit password</button>
71 <td><button id="switch_to_admin_enter">admin mode</button></td>
73 <button id="switch_to_control_pw_type">change protection character password</button>
74 <button id="switch_to_control_tile_type">change protection areas</button>
75 <button id="switch_to_admin_thing_protect">change thing protection</button>
76 <button id="toggle_tile_draw">toggle protection character drawing</button>
81 <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 />
83 <li>move up (square grid): <input id="key_square_move_up" type="text" value="w" /> (hint: ArrowUp)
84 <li>move left (square grid): <input id="key_square_move_left" type="text" value="a" /> (hint: ArrowLeft)
85 <li>move down (square grid): <input id="key_square_move_down" type="text" value="s" /> (hint: ArrowDown)
86 <li>move right (square grid): <input id="key_square_move_right" type="text" value="d" /> (hint: ArrowRight)
87 <li>move up-left (hex grid): <input id="key_hex_move_upleft" type="text" value="w" />
88 <li>move up-right (hex grid): <input id="key_hex_move_upright" type="text" value="e" />
89 <li>move right (hex grid): <input id="key_hex_move_right" type="text" value="d" />
90 <li>move down-right (hex grid): <input id="key_hex_move_downright" type="text" value="x" />
91 <li>move down-left (hex grid): <input id="key_hex_move_downleft" type="text" value="y" />
92 <li>move left (hex grid): <input id="key_hex_move_left" type="text" value="a" />
93 <li>help: <input id="key_help" type="text" value="h" />
94 <li>flatten surroundings: <input id="key_flatten" type="text" value="F" />
95 <li>teleport: <input id="key_teleport" type="text" value="p" />
96 <li>pick up thing: <input id="key_take_thing" type="text" value="z" />
97 <li>drop thing: <input id="key_drop_thing" type="text" value="u" />
98 <li><input id="key_switch_to_chat" type="text" value="t" />
99 <li><input id="key_switch_to_play" type="text" value="p" />
100 <li><input id="key_switch_to_study" type="text" value="?" />
101 <li><input id="key_switch_to_edit" type="text" value="E" />
102 <li><input id="key_switch_to_write" type="text" value="m" />
103 <li><input id="key_switch_to_name_thing" type="text" value="N" />
104 <li><input id="key_switch_to_password" type="text" value="P" />
105 <li><input id="key_switch_to_admin_enter" type="text" value="A" />
106 <li><input id="key_switch_to_control_pw_type" type="text" value="C" />
107 <li><input id="key_switch_to_control_tile_type" type="text" value="Q" />
108 <li><input id="key_switch_to_admin_thing_protect" type="text" value="T" />
109 <li><input id="key_switch_to_annotate" type="text" value="M" />
110 <li><input id="key_switch_to_portal" type="text" value="T" />
111 <li>toggle map view: <input id="key_toggle_map_mode" type="text" value="L" />
112 <li>toggle protection character drawing: <input id="key_toggle_tile_draw" type="text" value="m" />
117 let websocket_location = "wss://plomlompom.com/rogue_chat/";
118 //let websocket_location = "ws://localhost:8000/";
123 'long': 'This mode allows you to interact with the map in various ways.'
127 '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.'},
129 'short': 'world edit',
130 '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.'
133 'short': 'name thing',
134 'long': 'Give name to/change name of thing here.'
136 'admin_thing_protect': {
137 'short': 'change thing protection',
138 'long': 'Change protection character for thing here.'
141 'short': 'change terrain',
142 '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.'
145 'short': 'change protection character password',
146 '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.'
149 'short': 'change protection character password',
150 '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.'
152 'control_tile_type': {
153 'short': 'change tiles protection',
154 '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.'
156 'control_tile_draw': {
157 'short': 'change tiles protection',
158 '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.'
161 'short': 'annotate tile',
162 '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.'
165 'short': 'edit portal',
166 '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.'
170 '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:'
174 'long': 'Enter your player name.'
176 'waiting_for_server': {
177 'short': 'waiting for server response',
178 'long': 'Waiting for a server response.'
181 'short': 'waiting for server response',
182 'long': 'Waiting for a server response.'
185 'short': 'set world edit password',
186 '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.'
189 'short': 'become admin',
190 'long': 'This mode allows you to become admin if you know an admin password.'
194 'long': 'This mode allows you access to actions limited to administrators.'
197 let key_descriptions = {
199 'flatten': 'flatten surroundings',
200 'teleport': 'teleport',
201 'take_thing': 'pick up thing',
202 'drop_thing': 'drop thing',
203 'toggle_map_mode': 'toggle map view',
204 'toggle_tile_draw': 'toggle protection character drawing',
205 'hex_move_upleft': 'up-left',
206 'hex_move_upright': 'up-right',
207 'hex_move_right': 'right',
208 'hex_move_left': 'left',
209 'hex_move_downleft': 'down-left',
210 'hex_move_downright': 'down-right',
211 'square_move_up': 'up',
212 'square_move_left': 'left',
213 'square_move_down': 'down',
214 'square_move_right': 'right',
216 for (const mode_name of Object.keys(mode_helps)) {
217 key_descriptions['switch_to_' + mode_name] = mode_helps[mode_name].short;
220 let rows_selector = document.getElementById("n_rows");
221 let cols_selector = document.getElementById("n_cols");
222 let key_selectors = document.querySelectorAll('[id^="key_"]');
224 for (const key_switch_selector of document.querySelectorAll('[id^="key_switch_to_"]')) {
225 const action = key_switch_selector.id.slice("key_switch_to_".length);
226 key_switch_selector.parentNode.prepend(mode_helps[action].short + ': ');
229 function restore_selector_value(selector) {
230 let stored_selection = window.localStorage.getItem(selector.id);
231 if (stored_selection) {
232 selector.value = stored_selection;
235 restore_selector_value(rows_selector);
236 restore_selector_value(cols_selector);
237 for (let key_selector of key_selectors) {
238 restore_selector_value(key_selector);
241 function escapeHTML(str) {
243 replace(/&/g, '&').
244 replace(/</g, '<').
245 replace(/>/g, '>').
246 replace(/'/g, ''').
247 replace(/"/g, '"');
253 initialize: function() {
254 this.rows = rows_selector.value;
255 this.cols = cols_selector.value;
256 this.pre_el = document.getElementById("terminal");
257 this.pre_el.style.color = this.foreground;
258 this.pre_el.style.backgroundColor = this.background;
261 for (let y = 0, x = 0; y <= this.rows; x++) {
262 if (x == this.cols) {
265 this.content.push(line);
267 if (y == this.rows) {
274 blink_screen: function() {
275 this.pre_el.style.color = this.background;
276 this.pre_el.style.backgroundColor = this.foreground;
278 this.pre_el.style.color = this.foreground;
279 this.pre_el.style.backgroundColor = this.background;
282 refresh: function() {
283 let pre_content = '';
284 for (let y = 0; y < this.rows; y++) {
285 let line = this.content[y].join('');
287 if (y in tui.links) {
289 for (let span of tui.links[y]) {
290 chunks.push(escapeHTML(line.slice(start_x, span[0])));
291 chunks.push('<a target="_blank" href="');
292 chunks.push(escapeHTML(span[2]));
294 chunks.push(escapeHTML(line.slice(span[0], span[1])));
298 chunks.push(escapeHTML(line.slice(start_x)));
300 chunks = [escapeHTML(line)];
302 for (const chunk of chunks) {
303 pre_content += chunk;
307 this.pre_el.innerHTML = pre_content;
309 write: function(start_y, start_x, msg) {
310 for (let x = start_x, i = 0; x < this.cols && i < msg.length; x++, i++) {
311 this.content[start_y][x] = msg[i];
314 drawBox: function(start_y, start_x, height, width) {
315 let end_y = start_y + height;
316 let end_x = start_x + width;
317 for (let y = start_y, x = start_x; y < this.rows; x++) {
325 this.content[y][x] = ' ';
329 terminal.initialize();
332 tokenize: function(str) {
337 for (let i = 0; i < str.length; i++) {
343 } else if (c == '\\') {
345 } else if (c == '"') {
350 } else if (c == '"') {
352 } else if (c === ' ') {
353 if (token.length > 0) {
361 if (token.length > 0) {
366 parse_yx: function(position_string) {
367 let coordinate_strings = position_string.split(',')
368 let position = [0, 0];
369 position[0] = parseInt(coordinate_strings[0].slice(2));
370 position[1] = parseInt(coordinate_strings[1].slice(2));
382 init: function(url) {
384 this.websocket = new WebSocket(this.url);
385 this.websocket.onopen = function(event) {
386 server.connected = true;
387 game.thing_types = {};
389 server.send(['TASKS']);
390 server.send(['TERRAINS']);
391 server.send(['THING_TYPES']);
392 tui.log_msg("@ server connected! :)");
393 tui.switch_mode('login');
395 this.websocket.onclose = function(event) {
396 server.connected = false;
397 tui.switch_mode('waiting_for_server');
398 tui.log_msg("@ server disconnected :(");
400 this.websocket.onmessage = this.handle_event;
402 reconnect_to: function(url) {
403 this.websocket.close();
406 send: function(tokens) {
407 this.websocket.send(unparser.untokenize(tokens));
409 handle_event: function(event) {
410 let tokens = parser.tokenize(event.data);
411 if (tokens[0] === 'TURN') {
412 game.turn_complete = false;
413 explorer.empty_info_db();
416 game.turn = parseInt(tokens[1]);
417 } else if (tokens[0] === 'THING') {
418 let t = game.get_thing(tokens[4], true);
419 t.position = parser.parse_yx(tokens[1]);
421 t.protection = tokens[3];
422 } else if (tokens[0] === 'THING_NAME') {
423 let t = game.get_thing(tokens[1], false);
427 } else if (tokens[0] === 'THING_CHAR') {
428 let t = game.get_thing(tokens[1], false);
430 t.player_char = tokens[2];
432 } else if (tokens[0] === 'TASKS') {
433 game.tasks = tokens[1].split(',');
434 tui.mode_write.legal = game.tasks.includes('WRITE');
435 } else if (tokens[0] === 'THING_TYPE') {
436 game.thing_types[tokens[1]] = tokens[2]
437 } else if (tokens[0] === 'TERRAIN') {
438 game.terrains[tokens[1]] = tokens[2]
439 } else if (tokens[0] === 'MAP') {
440 game.map_geometry = tokens[1];
442 game.map_size = parser.parse_yx(tokens[2]);
444 } else if (tokens[0] === 'FOV') {
446 } else if (tokens[0] === 'MAP_CONTROL') {
447 game.map_control = tokens[1]
448 } else if (tokens[0] === 'GAME_STATE_COMPLETE') {
449 game.turn_complete = true;
450 if (tui.mode.name == 'post_login_wait') {
451 tui.switch_mode('play');
452 } else if (tui.mode.name == 'study') {
453 explorer.query_info();
456 } else if (tokens[0] === 'CHAT') {
457 tui.log_msg('# ' + tokens[1], 1);
458 } else if (tokens[0] === 'PLAYER_ID') {
459 game.player_id = parseInt(tokens[1]);
460 } else if (tokens[0] === 'LOGIN_OK') {
461 this.send(['GET_GAMESTATE']);
462 tui.switch_mode('post_login_wait');
463 } else if (tokens[0] === 'ADMIN_OK') {
465 tui.log_msg('@ you now have admin rights');
466 tui.switch_mode('admin');
467 } else if (tokens[0] === 'PORTAL') {
468 let position = parser.parse_yx(tokens[1]);
469 game.portals[position] = tokens[2];
470 } else if (tokens[0] === 'ANNOTATION_HINT') {
471 let position = parser.parse_yx(tokens[1]);
472 explorer.info_hints = explorer.info_hints.concat([position]);
473 } else if (tokens[0] === 'ANNOTATION') {
474 let position = parser.parse_yx(tokens[1]);
475 explorer.update_info_db(position, tokens[2]);
477 } else if (tokens[0] === 'UNHANDLED_INPUT') {
478 tui.log_msg('? unknown command');
479 } else if (tokens[0] === 'PLAY_ERROR') {
480 tui.log_msg('? ' + tokens[1]);
481 terminal.blink_screen();
482 } else if (tokens[0] === 'ARGUMENT_ERROR') {
483 tui.log_msg('? syntax error: ' + tokens[1]);
484 } else if (tokens[0] === 'GAME_ERROR') {
485 tui.log_msg('? game error: ' + tokens[1]);
486 } else if (tokens[0] === 'PONG') {
489 tui.log_msg('? unhandled input: ' + event.data);
495 quote: function(str) {
497 for (let i = 0; i < str.length; i++) {
499 if (['"', '\\'].includes(c)) {
505 return quoted.join('');
507 to_yx: function(yx_coordinate) {
508 return "Y:" + yx_coordinate[0] + ",X:" + yx_coordinate[1];
510 untokenize: function(tokens) {
511 let quoted_tokens = [];
512 for (let token of tokens) {
513 quoted_tokens.push(this.quote(token));
515 return quoted_tokens.join(" ");
520 constructor(name, has_input_prompt=false, shows_info=false,
521 is_intro=false, is_single_char_entry=false) {
523 this.short_desc = mode_helps[name].short;
524 this.available_modes = [];
525 this.available_actions = [];
526 this.has_input_prompt = has_input_prompt;
527 this.shows_info= shows_info;
528 this.is_intro = is_intro;
529 this.help_intro = mode_helps[name].long;
530 this.is_single_char_entry = is_single_char_entry;
533 *iter_available_modes() {
534 for (let mode_name of this.available_modes) {
535 let mode = tui['mode_' + mode_name];
539 let key = tui.keys['switch_to_' + mode.name];
543 list_available_modes() {
545 if (this.available_modes.length > 0) {
546 msg += 'Other modes available from here:\n';
547 for (let [mode, key] of this.iter_available_modes()) {
548 msg += '[' + key + '] – ' + mode.short_desc + '\n';
553 mode_switch_on_key(key_event) {
554 for (let [mode, key] of this.iter_available_modes()) {
555 if (key_event.key == key) {
556 event.preventDefault();
557 tui.switch_mode(mode.name);
569 window_width: terminal.cols / 2,
577 mode_waiting_for_server: new Mode('waiting_for_server',
579 mode_login: new Mode('login', true, false, true),
580 mode_post_login_wait: new Mode('post_login_wait'),
581 mode_chat: new Mode('chat', true),
582 mode_annotate: new Mode('annotate', true, true),
583 mode_play: new Mode('play'),
584 mode_study: new Mode('study', false, true),
585 mode_write: new Mode('write', false, false, false, true),
586 mode_edit: new Mode('edit'),
587 mode_control_pw_type: new Mode('control_pw_type', true),
588 mode_admin_thing_protect: new Mode('admin_thing_protect', true),
589 mode_portal: new Mode('portal', true, true),
590 mode_password: new Mode('password', true),
591 mode_name_thing: new Mode('name_thing', true, true),
592 mode_admin_enter: new Mode('admin_enter', true),
593 mode_admin: new Mode('admin'),
594 mode_control_pw_pw: new Mode('control_pw_pw', true),
595 mode_control_tile_type: new Mode('control_tile_type', true),
596 mode_control_tile_draw: new Mode('control_tile_draw'),
598 'flatten': 'FLATTEN_SURROUNDINGS',
599 'take_thing': 'PICK_UP',
600 'drop_thing': 'DROP',
604 this.mode_chat.available_modes = ["play", "study", "edit", "admin_enter"]
605 this.mode_play.available_modes = ["chat", "study", "edit", "admin_enter"]
606 this.mode_play.available_actions = ["move", "take_thing", "drop_thing", "teleport"];
607 this.mode_study.available_modes = ["chat", "play", "admin_enter", "edit"]
608 this.mode_study.available_actions = ["toggle_map_mode", "move_explorer"];
609 this.mode_admin.available_modes = ["admin_thing_protect", "control_pw_type",
610 "control_tile_type", "chat",
611 "study", "play", "edit"]
612 this.mode_admin.available_actions = ["move"];
613 this.mode_control_tile_draw.available_modes = ["admin_enter"]
614 this.mode_control_tile_draw.available_actions = ["toggle_tile_draw"];
615 this.mode_edit.available_modes = ["write", "annotate", "portal", "name_thing",
616 "password", "chat", "study", "play",
618 this.mode_edit.available_actions = ["move", "flatten", "toggle_map_mode"]
619 this.mode = this.mode_waiting_for_server;
620 this.inputEl = document.getElementById("input");
621 this.inputEl.focus();
622 this.recalc_input_lines();
623 this.height_header = this.height_turn_line + this.height_mode_line;
624 this.log_msg("@ waiting for server connection ...");
627 init_keys: function() {
628 document.getElementById("move_table").hidden = true;
630 for (let key_selector of key_selectors) {
631 this.keys[key_selector.id.slice(4)] = key_selector.value;
633 this.movement_keys = {};
634 let geometry_prefix = 'undefinedMapGeometry_';
635 if (game.map_geometry) {
636 geometry_prefix = game.map_geometry.toLowerCase() + '_';
638 for (const key_name of Object.keys(key_descriptions)) {
639 if (key_name.startsWith(geometry_prefix)) {
640 let direction = key_name.split('_')[2].toUpperCase();
641 let key = this.keys[key_name];
642 this.movement_keys[key] = direction;
645 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
646 move_button.hidden = true;
648 for (const move_button of document.querySelectorAll('[id^="' + geometry_prefix + 'move_"]')) {
649 document.getElementById("move_table").hidden = false;
650 move_button.hidden = false;
652 for (let el of document.getElementsByTagName("button")) {
653 let action_desc = key_descriptions[el.id];
654 let action_key = '[' + this.keys[el.id] + ']';
655 el.innerHTML = escapeHTML(action_desc) + '<br /><span class="keyboard_controlled">' + escapeHTML(action_key) + '</span>';
658 task_action_on: function(action) {
659 return game.tasks.includes(this.action_tasks[action]);
661 switch_mode: function(mode_name) {
662 if (this.mode.name == 'control_tile_draw') {
663 tui.log_msg('@ finished tile protection drawing.')
665 this.tile_draw = false;
666 if (mode_name == 'admin_enter' && this.is_admin) {
668 } else if (['name_thing', 'admin_thing_protect'].includes(mode_name)) {
669 let player_position = game.things[game.player_id].position;
671 for (let t_id in game.things) {
672 if (t_id == game.player_id) {
675 let t = game.things[t_id];
676 if (player_position[0] == t.position[0] && player_position[1] == t.position[1]) {
682 terminal.blink_screen();
683 this.log_msg('? not standing over thing');
686 this.selected_thing_id = thing_id;
689 this.mode = this['mode_' + mode_name];
690 if (["control_tile_draw", "control_tile_type", "control_pw_type"].includes(this.mode.name)) {
691 this.map_mode = 'protections';
692 } else if (this.mode.name != "edit") {
693 this.map_mode = 'terrain + things';
695 if (this.mode.has_input_prompt || this.mode.is_single_char_entry) {
696 this.inputEl.focus();
698 if (game.player_id in game.things && (this.mode.shows_info || this.mode.name == 'control_tile_draw')) {
699 explorer.position = game.things[game.player_id].position;
700 if (this.mode.shows_info) {
701 explorer.query_info();
704 this.inputEl.value = "";
705 this.restore_input_values();
706 for (let el of document.getElementsByTagName("button")) {
709 document.getElementById("help").disabled = false;
710 for (const action of this.mode.available_actions) {
711 if (["move", "move_explorer"].includes(action)) {
712 for (const move_key of document.querySelectorAll('[id*="_move_"]')) {
713 move_key.disabled = false;
715 } else if (Object.keys(this.action_tasks).includes(action)) {
716 if (this.task_action_on(action)) {
717 document.getElementById(action).disabled = false;
720 document.getElementById(action).disabled = false;
723 for (const mode_name of this.mode.available_modes) {
724 document.getElementById('switch_to_' + mode_name).disabled = false;
726 if (this.mode.name == 'login') {
727 if (this.login_name) {
728 server.send(['LOGIN', this.login_name]);
730 this.log_msg("? need login name");
732 } else if (this.mode.is_single_char_entry) {
733 this.show_help = true;
734 } else if (this.mode.name == 'admin_enter') {
735 this.log_msg('@ enter admin password:')
736 } else if (this.mode.name == 'control_pw_type') {
737 this.log_msg('@ enter protection character for which you want to change the password:')
738 } else if (this.mode.name == 'control_tile_type') {
739 this.log_msg('@ enter protection character which you want to draw:')
740 } else if (this.mode.name == 'admin_thing_protect') {
741 this.log_msg('@ enter thing protection character:')
742 } else if (this.mode.name == 'control_pw_pw') {
743 this.log_msg('@ enter protection password for "' + this.tile_control_char + '":');
744 } else if (this.mode.name == 'control_tile_draw') {
745 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 + '].')
749 offset_links: function(offset, links) {
750 for (let y in links) {
751 let real_y = offset[0] + parseInt(y);
752 if (!this.links[real_y]) {
753 this.links[real_y] = [];
755 for (let link of links[y]) {
756 const offset_link = [link[0] + offset[1], link[1] + offset[1], link[2]];
757 this.links[real_y].push(offset_link);
761 restore_input_values: function() {
762 if (this.mode.name == 'annotate' && explorer.position in explorer.info_db) {
763 let info = explorer.info_db[explorer.position];
764 if (info != "(none)") {
765 this.inputEl.value = info;
767 } else if (this.mode.name == 'portal' && explorer.position in game.portals) {
768 let portal = game.portals[explorer.position]
769 this.inputEl.value = portal;
770 } else if (this.mode.name == 'password') {
771 this.inputEl.value = this.password;
772 } else if (this.mode.name == 'name_thing') {
773 let t = game.get_thing(this.selected_thing_id);
775 this.inputEl.value = t.name_;
777 } else if (this.mode.name == 'admin_thing_protect') {
778 let t = game.get_thing(this.selected_thing_id);
779 if (t && t.protection) {
780 this.inputEl.value = t.protection;
784 recalc_input_lines: function() {
785 if (this.mode.has_input_prompt) {
787 [this.input_lines, _] = this.msg_into_lines_of_width(this.input_prompt + this.inputEl.value, this.window_width);
789 this.input_lines = [];
791 this.height_input = this.input_lines.length;
793 msg_into_lines_of_width: function(msg, width) {
794 function push_inner_link(y, end_x) {
795 if (!inner_links[y]) {
798 inner_links[y].push([url_start_x, end_x, url]);
800 const matches = msg.matchAll(/https?:\/\/[^\s]+/g)
803 for (const match of matches) {
804 const url = match[0];
805 const url_start = match.index;
806 const url_end = match.index + match[0].length;
807 link_data[url_start] = url;
808 url_ends.push(url_end);
812 let inner_links = {};
816 for (let i = 0, x = 0, y = 0; i < msg.length; i++, x++) {
817 if (x >= width || msg[i] == "\n") {
819 push_inner_link(y, chunk.length);
821 if (url_ends[0] == i) {
829 if (msg[i] == "\n") {
834 if (msg[i] != "\n") {
837 if (i in link_data) {
841 } else if (url_ends[0] == i) {
843 push_inner_link(y, x);
849 push_inner_link(lines.length - 1, chunk.length);
851 return [lines, inner_links];
853 log_msg: function(msg) {
855 while (this.log.length > 100) {
860 draw_map: function() {
861 let map_lines_split = [];
863 for (let i = 0, j = 0; i < game.map.length; i++, j++) {
864 if (j == game.map_size[1]) {
865 map_lines_split.push(line);
869 if (this.map_mode == 'protections') {
870 line.push(game.map_control[i] + ' ');
872 line.push(game.map[i] + ' ');
875 map_lines_split.push(line);
876 if (this.map_mode == 'terrain + annotations') {
877 for (const coordinate of explorer.info_hints) {
878 map_lines_split[coordinate[0]][coordinate[1]] = 'A ';
880 } else if (this.map_mode == 'terrain + things') {
881 for (const p in game.portals) {
882 let coordinate = p.split(',')
883 let original = map_lines_split[coordinate[0]][coordinate[1]];
884 map_lines_split[coordinate[0]][coordinate[1]] = original[0] + 'P';
886 let used_positions = [];
887 for (const thing_id in game.things) {
888 let t = game.things[thing_id];
889 let symbol = game.thing_types[t.type_];
892 meta_char = t.player_char;
894 if (used_positions.includes(t.position.toString())) {
897 map_lines_split[t.position[0]][t.position[1]] = symbol + meta_char;
898 used_positions.push(t.position.toString());
901 let player = game.things[game.player_id];
902 if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
903 map_lines_split[explorer.position[0]][explorer.position[1]] = '??';
904 } else if (tui.map_mode != 'terrain + things') {
905 map_lines_split[player.position[0]][player.position[1]] = '??';
908 if (game.map_geometry == 'Square') {
909 for (let line_split of map_lines_split) {
910 map_lines.push(line_split.join(''));
912 } else if (game.map_geometry == 'Hex') {
914 for (let line_split of map_lines_split) {
915 map_lines.push(' '.repeat(indent) + line_split.join(''));
923 let window_center = [terminal.rows / 2, this.window_width / 2];
924 let center_position = [player.position[0], player.position[1]];
925 if (tui.mode.shows_info || tui.mode.name == 'control_tile_draw') {
926 center_position = [explorer.position[0], explorer.position[1]];
928 center_position[1] = center_position[1] * 2;
929 let offset = [center_position[0] - window_center[0],
930 center_position[1] - window_center[1]]
931 if (game.map_geometry == 'Hex' && offset[0] % 2) {
934 let term_y = Math.max(0, -offset[0]);
935 let term_x = Math.max(0, -offset[1]);
936 let map_y = Math.max(0, offset[0]);
937 let map_x = Math.max(0, offset[1]);
938 for (; term_y < terminal.rows && map_y < game.map_size[0]; term_y++, map_y++) {
939 let to_draw = map_lines[map_y].slice(map_x, this.window_width + offset[1]);
940 terminal.write(term_y, term_x, to_draw);
943 draw_mode_line: function() {
944 let help = 'hit [' + this.keys.help + '] for help';
945 if (this.mode.has_input_prompt) {
946 help = 'enter /help for help';
948 terminal.write(0, this.window_width, 'MODE: ' + this.mode.short_desc + ' – ' + help);
950 draw_turn_line: function(n) {
951 terminal.write(1, this.window_width, 'TURN: ' + game.turn);
953 draw_history: function() {
954 let log_display_lines = [];
956 let y_offset_in_log = 0;
957 for (let line of this.log) {
958 let [new_lines, link_data] = this.msg_into_lines_of_width(line,
960 log_display_lines = log_display_lines.concat(new_lines);
961 for (const y in link_data) {
962 const rel_y = y_offset_in_log + parseInt(y);
963 log_links[rel_y] = [];
964 for (let link of link_data[y]) {
965 log_links[rel_y].push(link);
968 y_offset_in_log += new_lines.length;
970 let i = log_display_lines.length - 1;
971 for (let y = terminal.rows - 1 - this.height_input;
972 y >= this.height_header && i >= 0;
974 terminal.write(y, this.window_width, log_display_lines[i]);
976 for (const key of Object.keys(log_links)) {
977 if (parseInt(key) <= i) {
978 delete log_links[key];
981 let offset = [terminal.rows - this.height_input - log_display_lines.length,
983 this.offset_links(offset, log_links);
985 draw_info: function() {
986 let [lines, link_data] = this.msg_into_lines_of_width(explorer.get_info(),
988 let offset = [this.height_header, this.window_width];
989 for (let y = offset[0], i = 0; y < terminal.rows && i < lines.length; y++, i++) {
990 terminal.write(y, offset[1], lines[i]);
992 this.offset_links(offset, link_data);
994 draw_input: function() {
995 if (this.mode.has_input_prompt) {
996 for (let y = terminal.rows - this.height_input, i = 0; i < this.input_lines.length; y++, i++) {
997 terminal.write(y, this.window_width, this.input_lines[i]);
1001 draw_help: function() {
1002 let movement_keys_desc = '';
1003 if (!this.mode.is_intro) {
1004 movement_keys_desc = Object.keys(this.movement_keys).join(',');
1006 let content = this.mode.short_desc + " help\n\n" + this.mode.help_intro + "\n\n";
1007 if (this.mode.name == 'chat') {
1008 content += '/nick NAME – re-name yourself to NAME\n';
1009 content += '/' + this.keys.switch_to_play + ' or /play – switch to play mode\n';
1010 content += '/' + this.keys.switch_to_study + ' or /study – switch to study mode\n';
1011 content += '/' + this.keys.switch_to_edit + ' or /edit – switch to world edit mode\n';
1012 content += '/' + this.keys.switch_to_admin_enter + ' or /admin – switch to admin mode\n';
1013 } else if (this.mode.available_actions.length > 0) {
1014 content += "Available actions:\n";
1015 for (let action of this.mode.available_actions) {
1016 if (Object.keys(this.action_tasks).includes(action)) {
1017 if (!this.task_action_on(action)) {
1021 if (action == 'move_explorer') {
1024 if (action == 'move') {
1025 content += "[" + movement_keys_desc + "] – move\n"
1027 content += "[" + this.keys[action] + "] – " + key_descriptions[action] + "\n";
1032 content += this.mode.list_available_modes();
1034 if (!this.mode.has_input_prompt) {
1035 start_x = this.window_width
1037 terminal.drawBox(0, start_x, terminal.rows, this.window_width);
1038 let [lines, _] = this.msg_into_lines_of_width(content, this.window_width);
1039 for (let y = 0, i = 0; y < terminal.rows && i < lines.length; y++, i++) {
1040 terminal.write(y, start_x, lines[i]);
1043 toggle_tile_draw: function() {
1044 if (tui.tile_draw) {
1045 tui.tile_draw = false;
1047 tui.tile_draw = true;
1050 toggle_map_mode: function() {
1051 if (tui.map_mode == 'terrain only') {
1052 tui.map_mode = 'terrain + annotations';
1053 } else if (tui.map_mode == 'terrain + annotations') {
1054 tui.map_mode = 'terrain + things';
1055 } else if (tui.map_mode == 'terrain + things') {
1056 tui.map_mode = 'protections';
1057 } else if (tui.map_mode == 'protections') {
1058 tui.map_mode = 'terrain only';
1061 full_refresh: function() {
1063 terminal.drawBox(0, 0, terminal.rows, terminal.cols);
1064 this.recalc_input_lines();
1065 if (this.mode.is_intro) {
1066 this.draw_history();
1069 if (game.turn_complete) {
1071 this.draw_turn_line();
1073 this.draw_mode_line();
1074 if (this.mode.shows_info) {
1077 this.draw_history();
1081 if (this.show_help) {
1093 this.map_control = "";
1094 this.map_size = [0,0];
1095 this.player_id = -1;
1099 get_thing: function(id_, create_if_not_found=false) {
1100 if (id_ in game.things) {
1101 return game.things[id_];
1102 } else if (create_if_not_found) {
1103 let t = new Thing([0,0]);
1104 game.things[id_] = t;
1108 move: function(start_position, direction) {
1109 let target = [start_position[0], start_position[1]];
1110 if (direction == 'LEFT') {
1112 } else if (direction == 'RIGHT') {
1114 } else if (game.map_geometry == 'Square') {
1115 if (direction == 'UP') {
1117 } else if (direction == 'DOWN') {
1120 } else if (game.map_geometry == 'Hex') {
1121 let start_indented = start_position[0] % 2;
1122 if (direction == 'UPLEFT') {
1124 if (!start_indented) {
1127 } else if (direction == 'UPRIGHT') {
1129 if (start_indented) {
1132 } else if (direction == 'DOWNLEFT') {
1134 if (!start_indented) {
1137 } else if (direction == 'DOWNRIGHT') {
1139 if (start_indented) {
1144 if (target[0] < 0 || target[1] < 0 ||
1145 target[0] >= this.map_size[0] || target[1] >= this.map_size[1]) {
1150 teleport: function() {
1151 let player = this.get_thing(game.player_id);
1152 if (player.position in this.portals) {
1153 server.reconnect_to(this.portals[player.position]);
1155 terminal.blink_screen();
1156 tui.log_msg('? not standing on portal')
1164 server.init(websocket_location);
1170 move: function(direction) {
1171 let target = game.move(this.position, direction);
1173 this.position = target
1174 if (tui.mode.shows_info) {
1176 } else if (tui.tile_draw) {
1177 this.send_tile_control_command();
1180 terminal.blink_screen();
1183 update_info_db: function(yx, str) {
1184 this.info_db[yx] = str;
1185 if (tui.mode.name == 'study') {
1189 empty_info_db: function() {
1191 this.info_hints = [];
1192 if (tui.mode.name == 'study') {
1196 query_info: function() {
1197 server.send(["GET_ANNOTATION", unparser.to_yx(explorer.position)]);
1199 get_info: function() {
1200 let info = "MAP VIEW: " + tui.map_mode + "\n";
1201 let position_i = this.position[0] * game.map_size[1] + this.position[1];
1202 if (game.fov[position_i] != '.') {
1203 return info + 'outside field of view';
1205 let terrain_char = game.map[position_i]
1206 let terrain_desc = '?'
1207 if (game.terrains[terrain_char]) {
1208 terrain_desc = game.terrains[terrain_char];
1210 info += 'TERRAIN: "' + terrain_char + '" / ' + terrain_desc + "\n";
1211 let protection = game.map_control[position_i];
1212 if (protection == '.') {
1213 protection = 'unprotected';
1215 info += 'PROTECTION: ' + protection + '\n';
1216 for (let t_id in game.things) {
1217 let t = game.things[t_id];
1218 if (t.position[0] == this.position[0] && t.position[1] == this.position[1]) {
1219 let symbol = game.thing_types[t.type_];
1220 let protection = t.protection;
1221 if (protection == '.') {
1222 protection = 'unprotected';
1224 info += "THING: " + t.type_ + " / protection: " + protection + " / " + symbol;
1225 if (t.player_char) {
1226 info += t.player_char;
1229 info += " (" + t.name_ + ")";
1234 if (this.position in game.portals) {
1235 info += "PORTAL: " + game.portals[this.position] + "\n";
1237 if (this.position in this.info_db) {
1238 info += "ANNOTATIONS: " + this.info_db[this.position];
1240 info += 'waiting …';
1244 annotate: function(msg) {
1245 if (msg.length == 0) {
1246 msg = " "; // triggers annotation deletion
1248 server.send(["ANNOTATE", unparser.to_yx(explorer.position), msg, tui.password]);
1250 set_portal: function(msg) {
1251 if (msg.length == 0) {
1252 msg = " "; // triggers portal deletion
1254 server.send(["PORTAL", unparser.to_yx(explorer.position), msg, tui.password]);
1256 send_tile_control_command: function() {
1257 server.send(["SET_TILE_CONTROL", unparser.to_yx(this.position), tui.tile_control_char]);
1261 tui.inputEl.addEventListener('input', (event) => {
1262 if (tui.mode.has_input_prompt) {
1263 let max_length = tui.window_width * terminal.rows - tui.input_prompt.length;
1264 if (tui.inputEl.value.length > max_length) {
1265 tui.inputEl.value = tui.inputEl.value.slice(0, max_length);
1267 } else if (tui.mode.name == 'write' && tui.inputEl.value.length > 0) {
1268 server.send(["TASK:WRITE", tui.inputEl.value[0], tui.password]);
1269 tui.switch_mode('edit');
1273 document.onclick = function() {
1274 tui.show_help = false;
1276 tui.inputEl.addEventListener('keydown', (event) => {
1277 tui.show_help = false;
1278 if (event.key == 'Enter') {
1279 event.preventDefault();
1281 if (tui.mode.has_input_prompt && event.key == 'Enter' && tui.inputEl.value == '/help') {
1282 tui.show_help = true;
1283 tui.inputEl.value = "";
1284 tui.restore_input_values();
1285 } else if (!tui.mode.has_input_prompt && event.key == tui.keys.help
1286 && !tui.mode.is_single_char_entry) {
1287 tui.show_help = true;
1288 } else if (tui.mode.name == 'login' && event.key == 'Enter') {
1289 tui.login_name = tui.inputEl.value;
1290 server.send(['LOGIN', tui.inputEl.value]);
1291 tui.inputEl.value = "";
1292 } else if (tui.mode.name == 'control_pw_pw' && event.key == 'Enter') {
1293 if (tui.inputEl.value.length == 0) {
1294 tui.log_msg('@ aborted');
1296 server.send(['SET_MAP_CONTROL_PASSWORD',
1297 tui.tile_control_char, tui.inputEl.value]);
1298 tui.log_msg('@ sent new password for protection character "' + tui.tile_control_char + '".');
1300 tui.switch_mode('admin');
1301 } else if (tui.mode.name == 'portal' && event.key == 'Enter') {
1302 explorer.set_portal(tui.inputEl.value);
1303 tui.switch_mode('edit');
1304 } else if (tui.mode.name == 'name_thing' && event.key == 'Enter') {
1305 if (tui.inputEl.value.length == 0) {
1306 tui.inputEl.value = " ";
1308 server.send(["THING_NAME", tui.selected_thing_id, tui.inputEl.value,
1310 tui.switch_mode('edit');
1311 } else if (tui.mode.name == 'annotate' && event.key == 'Enter') {
1312 explorer.annotate(tui.inputEl.value);
1313 tui.switch_mode('edit');
1314 } else if (tui.mode.name == 'password' && event.key == 'Enter') {
1315 if (tui.inputEl.value.length == 0) {
1316 tui.inputEl.value = " ";
1318 tui.password = tui.inputEl.value
1319 tui.switch_mode('edit');
1320 } else if (tui.mode.name == 'admin_enter' && event.key == 'Enter') {
1321 server.send(['BECOME_ADMIN', tui.inputEl.value]);
1322 tui.switch_mode('play');
1323 } else if (tui.mode.name == 'control_pw_type' && event.key == 'Enter') {
1324 if (tui.inputEl.value.length != 1) {
1325 tui.log_msg('@ entered non-single-char, therefore aborted');
1326 tui.switch_mode('admin');
1328 tui.tile_control_char = tui.inputEl.value[0];
1329 tui.switch_mode('control_pw_pw');
1331 } else if (tui.mode.name == 'control_tile_type' && event.key == 'Enter') {
1332 if (tui.inputEl.value.length != 1) {
1333 tui.log_msg('@ entered non-single-char, therefore aborted');
1334 tui.switch_mode('admin');
1336 tui.tile_control_char = tui.inputEl.value[0];
1337 tui.switch_mode('control_tile_draw');
1339 } else if (tui.mode.name == 'admin_thing_protect' && event.key == 'Enter') {
1340 if (tui.inputEl.value.length != 1) {
1341 tui.log_msg('@ entered non-single-char, therefore aborted');
1343 server.send(['THING_PROTECTION', tui.selected_thing_id, tui.inputEl.value])
1344 tui.log_msg('@ sent new protection character for thing');
1346 tui.switch_mode('admin');
1347 } else if (tui.mode.name == 'chat' && event.key == 'Enter') {
1348 let tokens = parser.tokenize(tui.inputEl.value);
1349 if (tokens.length > 0 && tokens[0].length > 0) {
1350 if (tui.inputEl.value[0][0] == '/') {
1351 if (tokens[0].slice(1) == 'play' || tokens[0][1] == tui.keys.switch_to_play) {
1352 tui.switch_mode('play');
1353 } else if (tokens[0].slice(1) == 'study' || tokens[0][1] == tui.keys.switch_to_study) {
1354 tui.switch_mode('study');
1355 } else if (tokens[0].slice(1) == 'edit' || tokens[0][1] == tui.keys.switch_to_edit) {
1356 tui.switch_mode('edit');
1357 } else if (tokens[0].slice(1) == 'admin' || tokens[0][1] == tui.keys.switch_to_admin_enter) {
1358 tui.switch_mode('admin_enter');
1359 } else if (tokens[0].slice(1) == 'nick') {
1360 if (tokens.length > 1) {
1361 server.send(['NICK', tokens[1]]);
1363 tui.log_msg('? need new name');
1366 tui.log_msg('? unknown command');
1369 server.send(['ALL', tui.inputEl.value]);
1371 } else if (tui.inputEl.valuelength > 0) {
1372 server.send(['ALL', tui.inputEl.value]);
1374 tui.inputEl.value = "";
1375 } else if (tui.mode.name == 'play') {
1376 if (tui.mode.mode_switch_on_key(event)) {
1378 } else if (event.key === tui.keys.take_thing && tui.task_action_on('take_thing')) {
1379 server.send(["TASK:PICK_UP"]);
1380 } else if (event.key === tui.keys.drop_thing && tui.task_action_on('drop_thing')) {
1381 server.send(["TASK:DROP"]);
1382 } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1383 server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1384 } else if (event.key === tui.keys.teleport) {
1387 } else if (tui.mode.name == 'study') {
1388 if (tui.mode.mode_switch_on_key(event)) {
1390 } else if (event.key in tui.movement_keys) {
1391 explorer.move(tui.movement_keys[event.key]);
1392 } else if (event.key == tui.keys.toggle_map_mode) {
1393 tui.toggle_map_mode();
1395 } else if (tui.mode.name == 'control_tile_draw') {
1396 if (tui.mode.mode_switch_on_key(event)) {
1398 } else if (event.key in tui.movement_keys) {
1399 explorer.move(tui.movement_keys[event.key]);
1400 } else if (event.key === tui.keys.toggle_tile_draw) {
1401 tui.toggle_tile_draw();
1403 } else if (tui.mode.name == 'admin') {
1404 if (tui.mode.mode_switch_on_key(event)) {
1406 } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1407 server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1409 } else if (tui.mode.name == 'edit') {
1410 if (tui.mode.mode_switch_on_key(event)) {
1412 } else if (event.key in tui.movement_keys && tui.task_action_on('move')) {
1413 server.send(['TASK:MOVE', tui.movement_keys[event.key]]);
1414 } else if (event.key === tui.keys.flatten && tui.task_action_on('flatten')) {
1415 server.send(["TASK:FLATTEN_SURROUNDINGS", tui.password]);
1416 } else if (event.key == tui.keys.toggle_map_mode) {
1417 tui.toggle_map_mode();
1423 rows_selector.addEventListener('input', function() {
1424 if (rows_selector.value % 4 != 0 || rows_selector.value < 24) {
1427 window.localStorage.setItem(rows_selector.id, rows_selector.value);
1428 terminal.initialize();
1431 cols_selector.addEventListener('input', function() {
1432 if (cols_selector.value % 4 != 0 || cols_selector.value < 80) {
1435 window.localStorage.setItem(cols_selector.id, cols_selector.value);
1436 terminal.initialize();
1437 tui.window_width = terminal.cols / 2,
1440 for (let key_selector of key_selectors) {
1441 key_selector.addEventListener('input', function() {
1442 window.localStorage.setItem(key_selector.id, key_selector.value);
1446 window.setInterval(function() {
1447 if (server.connected) {
1448 server.send(['PING']);
1450 server.reconnect_to(server.url);
1451 tui.log_msg('@ attempting reconnect …')
1454 window.setInterval(function() {
1456 let span_decoration = "none";
1457 if (document.activeElement == tui.inputEl) {
1458 val = "on (click outside terminal to change)";
1460 val = "off (click into terminal to change)";
1461 span_decoration = "line-through";
1463 document.getElementById("keyboard_control").textContent = val;
1464 for (const span of document.querySelectorAll('.keyboard_controlled')) {
1465 span.style.textDecoration = span_decoration;
1468 document.getElementById("terminal").onclick = function() {
1469 tui.inputEl.focus();
1471 document.getElementById("help").onclick = function() {
1472 tui.show_help = true;
1475 for (const switchEl of document.querySelectorAll('[id^="switch_to_"]')) {
1476 const mode = switchEl.id.slice("switch_to_".length);
1477 switchEl.onclick = function() {
1478 tui.switch_mode(mode);
1482 document.getElementById("toggle_tile_draw").onclick = function() {
1483 tui.toggle_tile_draw();
1485 document.getElementById("toggle_map_mode").onclick = function() {
1486 tui.toggle_map_mode();
1489 document.getElementById("take_thing").onclick = function() {
1490 server.send(['TASK:PICK_UP']);
1492 document.getElementById("drop_thing").onclick = function() {
1493 server.send(['TASK:DROP']);
1495 document.getElementById("flatten").onclick = function() {
1496 server.send(['TASK:FLATTEN_SURROUNDINGS', tui.password]);
1498 document.getElementById("teleport").onclick = function() {
1501 for (const move_button of document.querySelectorAll('[id*="_move_"]')) {
1502 let direction = move_button.id.split('_')[2].toUpperCase();
1503 move_button.onclick = function() {
1504 if (tui.mode.available_actions.includes("move")
1505 || tui.mode.available_actions.includes("move_explorer")) {
1506 server.send(['TASK:MOVE', direction]);
1508 explorer.move(direction);