From b871181bf4c363503f1c64812f2b0e142e6ca02f Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Mon, 31 Aug 2020 08:40:44 -0600 Subject: [PATCH] Add support for demos, terrible UI for it, and a clumsy pause button --- js/format-c2m.js | 95 ++++++++++++++++- js/main.js | 271 +++++++++++++++++++++++++++++++++++++++-------- style.css | 104 +++++++++++++++--- 3 files changed, 409 insertions(+), 61 deletions(-) diff --git a/js/format-c2m.js b/js/format-c2m.js index 05b8bf5..db5e5d3 100644 --- a/js/format-c2m.js +++ b/js/format-c2m.js @@ -1,6 +1,91 @@ import * as util from './format-util.js'; import TILE_TYPES from './tiletypes.js'; +const CC2_DEMO_INPUT_MASK = { + drop: 0x01, + down: 0x02, + left: 0x04, + right: 0x08, + up: 0x10, + swap: 0x20, + cycle: 0x40, +}; + +class CC2Demo { + constructor(buf) { + this.buf = buf; + this.bytes = new Uint8Array(buf); + + // byte 0 is unknown, always 0? + this.force_floor_seed = this.bytes[1]; + this.blob_seed = this.bytes[2]; + + + let l = this.bytes.length; + if (l % 2 === 0) { + l--; + } + for (let p = 3; p < l; p += 2) { + let delay = this.bytes[p]; + + let input_mask = this.bytes[p + 1]; + let input = new Set; + if ((input_mask & 0x80) !== 0) { + input.add('p2'); + } + for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) { + if ((input_mask & bit) !== 0) { + input.add(action); + } + } + console.log('demo step', delay, input); + } + } + + *[Symbol.iterator]() { + let l = this.bytes.length; + if (l % 2 === 0) { + l--; + // TODO assert last byte is terminating 0xff + } + let input = new Set; + let t = 0; + // 47 left 33 down means + // LLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLLDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD + // | * * * | * * * | * * * | * * * | * * * | * * * | * * + // | * * * | * * * | * * * | * * * | * * * | * * * | * * + for (let p = 3; p < l; p += 2) { + // The first byte measures how long the /previous/ input remains + // valid, so yield that first. Note that this is measured in 60Hz + // frames, so we need to convert to 20Hz tics by subtracting 3 + // frames at a time. + t += this.bytes[p]; + // t >= 4: almost right, desyncs just before yellow door + // t >= 3: skips a move, then desyncs trying to leave red door room + // t >= 2: skips a move, also desyncs before yellow door + // t >= 1: same as 2 + // t >= 0: same as 3 + while (t > 0) { + t -= 3; + console.log(t, input); + yield input; + } + + let input_mask = this.bytes[p + 1]; + let is_player_2 = ((input_mask & 0x80) !== 0); + // TODO handle player 2 + for (let [action, bit] of Object.entries(CC2_DEMO_INPUT_MASK)) { + if ((input_mask & bit) === 0) { + input.delete(action); + } + else { + input.add(action); + } + } + } + } +} + // TODO assert that direction + next match the tile types const TILE_ENCODING = { 0x01: 'floor', @@ -405,9 +490,13 @@ export function parse_level(buf) { } else if (section_type === 'KEY ') { } - else if (section_type === 'REPL') { - } - else if (section_type === 'PRPL') { + else if (section_type === 'REPL' || section_type === 'PRPL') { + // "Replay", i.e. demo solution + let data = section_buf; + if (section_type === 'PRPL') { + data = decompress(data); + } + level.demo = new CC2Demo(data); } else if (section_type === 'RDNY') { } diff --git a/js/main.js b/js/main.js index 14b9558..4c1e623 100644 --- a/js/main.js +++ b/js/main.js @@ -257,7 +257,7 @@ class Level { let stored_cell = this.stored_level.linear_cells[n]; n++; - + for (let template_tile of stored_cell) { let tile = Tile.from_template(template_tile, x, y); if (tile.type.is_player) { @@ -282,8 +282,9 @@ class Level { advance_tic(player_direction) { if (this.state !== 'playing') { - console.warn(`Level.advance_tic() called when state is ${this.state}`); - return; + // FIXME this breaks the step buttons; maybe pausing should be in game only + //console.warn(`Level.advance_tic() called when state is ${this.state}`); + //return; } // XXX this entire turn order is rather different in ms rules @@ -327,6 +328,7 @@ class Level { } else if (actor === this.player) { if (player_direction) { + console.log('--- player moving', player_direction); direction_preference = [player_direction]; actor.last_move_was_force = false; } @@ -412,7 +414,7 @@ class Level { return; } }); - + // Only bother touching the goal cell if we're not already trapped in this one // FIXME actually, this prevents flicking! if (! blocked) { @@ -522,26 +524,82 @@ class Level { } } +// TODO: +// - some kinda visual theme i guess lol +// - level /number/ +// - level password, if any +// - set name +// - timer! +// - intro splash with list of available levels +// - button: quit to splash +// - button: options +// - implement winning and show score for this level +// - show current score so far const GAME_UI_HTML = `
+
-
+
+ + + + +
+
+

Solution demo available

+
+ + + + +
+
+
+
`; +const ACTION_LABELS = { + up: '⬆️\ufe0f', + down: '⬇️\ufe0f', + left: '⬅️\ufe0f', + right: '➡️\ufe0f', + drop: '🚮', + cycle: '🔄', + swap: '👫', +}; +const ACTION_DIRECTIONS = { + up: 'north', + down: 'south', + left: 'west', + right: 'east', +}; class Game { constructor(stored_game, tileset) { this.stored_game = stored_game; this.tileset = tileset; + this.key_mapping = { + ArrowLeft: 'left', + ArrowRight: 'right', + ArrowUp: 'up', + ArrowDown: 'down', + w: 'up', + a: 'left', + s: 'down', + d: 'right', + q: 'drop', + e: 'cycle', + c: 'swap', + }; // TODO obey level options; allow overriding this.viewport_size_x = 9; @@ -559,6 +617,8 @@ class Game { this.time_el = this.container.querySelector('.time'); this.inventory_el = this.container.querySelector('.inventory'); this.bummer_el = this.container.querySelector('.bummer'); + this.input_el = this.container.querySelector('.input'); + this.demo_el = this.container.querySelector('.demo'); // Populate navigation this.nav_prev_button = this.nav_el.querySelector('.nav-prev'); @@ -576,6 +636,27 @@ class Game { } }); + // Bind buttons + this.container.querySelector('.controls .control-pause').addEventListener('click', ev => { + if (this.level.state === 'playing') { + this.level.state = 'paused'; + } + else if (this.level.state === 'paused') { + this.level.state = 'playing'; + } + ev.target.blur(); + }); + // Demo playback + this.container.querySelector('.demo .demo-step-1').addEventListener('click', ev => { + this.advance_by(1); + }); + this.container.querySelector('.demo .demo-step-4').addEventListener('click', ev => { + this.advance_by(4); + }); + this.container.querySelector('.demo .demo-step-20').addEventListener('click', ev => { + this.advance_by(20); + }); + // Populate inventory this._inventory_tiles = {}; let floor_tile = this.render_inventory_tile('floor'); @@ -599,48 +680,83 @@ class Game { this.next_player_move = null; this.player_used_move = false; let key_target = document.body; + this.previous_input = new Set; // actions that were held last tic + this.previous_action = null; // last direction we were moving, if any + this.current_keys = new Set; // keys that are currently held // TODO this could all probably be more rigorous but it's fine for now key_target.addEventListener('keydown', ev => { - let direction; - if (ev.key === 'ArrowDown') { - direction = 'south'; + if (this.key_mapping[ev.key]) { + this.current_keys.add(ev.key); + ev.stopPropagation(); + ev.preventDefault(); } - else if (ev.key === 'ArrowUp') { - direction = 'north'; - } - else if (ev.key === 'ArrowLeft') { - direction = 'west'; - } - else if (ev.key === 'ArrowRight') { - direction = 'east'; - } - - if (! direction) - return; - ev.stopPropagation(); - ev.preventDefault(); - - last_key = ev.key; - this.pending_player_move = direction; - this.next_player_move = direction; - this.player_used_move = false; }); key_target.addEventListener('keyup', ev => { - if (ev.key === last_key) { - last_key = null; - this.pending_player_move = null; - if (this.player_used_move) { - this.next_player_move = null; - } + if (this.key_mapping[ev.key]) { + this.current_keys.delete(ev.key); + ev.stopPropagation(); + ev.preventDefault(); } }); + // Populate demo scrubber + let scrubber_el = this.container.querySelector('.demo-scrubber'); + let scrubber_elements = {}; + for (let [action, label] of Object.entries(ACTION_LABELS)) { + let el = mk('li'); + scrubber_el.append(el); + scrubber_elements[action] = el; + } + this.demo_scrubber_marker = mk('div.demo-scrubber-marker'); + scrubber_el.append(this.demo_scrubber_marker); + + // Populate input debugger + this.input_el = this.container.querySelector('.input'); + this.input_action_elements = {}; + for (let [action, label] of Object.entries(ACTION_LABELS)) { + let el = mk('span.input-action', {'data-action': action}, label); + this.input_el.append(el); + this.input_action_elements[action] = el; + } + // Done with UI, now we can load a level this.load_level(0); this.redraw(); + // Fill in the scrubber + if (false && this.level.stored_level.demo) { + let input_starts = {}; + for (let action of Object.keys(ACTION_LABELS)) { + input_starts[action] = null; + } + let t = 0; + for (let input of this.level.stored_level.demo) { + for (let [action, t0] of Object.entries(input_starts)) { + if (input.has(action)) { + if (t0 === null) { + input_starts[action] = t; + } + } + else if (t0 !== null) { + let bar = mk('span.demo-scrubber-bar'); + bar.style.setProperty('--start-time', t0); + bar.style.setProperty('--end-time', t); + scrubber_elements[action].append(bar); + input_starts[action] = null; + } + } + t += 1; + } + this.demo = this.level.stored_level.demo[Symbol.iterator](); + } + else { + // TODO update these, as appropriate, when loading a level + this.input_el.style.display = 'none'; + this.demo_el.style.display = 'none'; + } + this.frame = 0; - this.tick++; + this.tic = 0; requestAnimationFrame(this.do_frame.bind(this)); } @@ -657,18 +773,80 @@ class Game { this.update_ui(); } + get_input() { + if (this.demo) { + let step = this.demo.next(); + if (step.done) { + return new Set; + } + else { + return step.value; + } + } + else { + // Convert input keys to actions. This is only done now + // because there might be multiple keys bound to one + // action, and it still counts as pressed as long as at + // least one key is held + let input = new Set; + for (let key of this.current_keys) { + input.add(this.key_mapping[key]); + } + return input; + } + } + + advance_by(tics) { + for (let i = 0; i < tics; i++) { + let input = this.get_input(); + let current_input = input; + if (! input.has('up') && ! input.has('down') && ! input.has('left') && ! input.has('right')) { + //input = this.previous_input; + } + + // Choose the movement direction based on the held keys. A + // newly pressed action takes priority; in the case of a tie, + // um, XXX ???? + let chosen_action = null; + let any_action = null; + for (let action of ['up', 'down', 'left', 'right']) { + if (input.has(action)) { + if (this.previous_input.has(action)) { + chosen_action = action; + } + any_action = action; + } + } + if (! chosen_action) { + // No keys are new, so check whether we were previously + // holding a key and are still doing it + if (this.previous_action && input.has(this.previous_action)) { + chosen_action = this.previous_action; + } + else { + // No dice, so use an arbitrary action + chosen_action = any_action; + } + } + + let player_move = chosen_action ? ACTION_DIRECTIONS[chosen_action] : null; + this.previous_action = chosen_action; + this.previous_input = current_input; + + this.level.advance_tic(player_move); + this.tic++; + } + this.redraw(); + this.update_ui(); + } + do_frame() { if (this.level.state === 'playing') { this.frame++; if (this.frame % 3 === 0) { - this.level.advance_tic(this.next_player_move); - this.next_player_move = this.pending_player_move; - this.player_used_move = true; - this.redraw(); + this.advance_by(1); } this.frame %= 60; - - this.update_ui(); } requestAnimationFrame(this.do_frame.bind(this)); @@ -702,8 +880,17 @@ class Game { this.inventory_el.append(mk('img', {src: this.render_inventory_tile(name)})); } } + + if (this.demo) { + this.demo_scrubber_marker.style.setProperty('--time', this.tic); + this.demo_scrubber_marker.scrollIntoView({inline: 'center'}); + } + + for (let action of Object.keys(ACTION_LABELS)) { + this.input_action_elements[action].classList.toggle('--pressed', this.previous_input.has(action)); + } } - + redraw() { let ctx = this.level_canvas.getContext('2d'); ctx.clearRect(0, 0, this.level_canvas.width, this.level_canvas.height); diff --git a/style.css b/style.css index 66feece..fe7667d 100644 --- a/style.css +++ b/style.css @@ -10,7 +10,7 @@ body { justify-content: center; align-items: center; - background: #606060; + background: #404040; } main { display: grid; @@ -21,6 +21,8 @@ main { "level time" min-content "level hint" 1fr "level inventory" min-content + "controls controls" + "demo demo" / min-content 12em ; gap: 1em; @@ -32,6 +34,10 @@ main { --scale: 2; } +button { + font-size: inherit; +} + .level { grid-area: level; @@ -41,6 +47,25 @@ main { display: block; width: calc(9 * var(--tile-width) * var(--scale)); } +.bummer { + grid-area: level; + + display: flex; + justify-content: center; + align-items: center; + + z-index: 99; + font-size: 48px; + padding: 25%; + background: #0009; + color: white; + text-align: center; + font-weight: bold; + text-shadow: 0 2px 1px black; +} +.bummer:empty { + display: none; +} .meta { grid-area: meta; @@ -85,22 +110,69 @@ main { .inventory img { width: calc(2 * var(--tile-width)); } -.bummer { - grid-area: level; +.controls { + grid-area: controls; +} +.demo { + grid-area: demo; +} - display: flex; - justify-content: center; - align-items: center; +.demo-scrubber { + position: relative; + overflow-x: auto; + padding: 0; + margin: 1em 0; + list-style: none; +} +.demo-scrubber li { + position: relative; + height: 1em; + margin: 2px 0; + background: #303030; +} +.demo-scrubber li .demo-scrubber-bar { + position: absolute; + height: 1em; + left: calc(var(--start-time) * 3px); + width: calc((var(--end-time) - var(--start-time)) * 3px); + background: #606060; +} +.demo-scrubber .demo-scrubber-marker { + position: absolute; + top: 0; + bottom: 0; + left: calc(var(--time) * 3px); + width: 2px; + margin-left: -1px; + background: darkred; + --time: 0; +} - z-index: 99; - font-size: 48px; - padding: 25%; - background: #0009; +/* Debug stuff */ +.input { + display: grid; + grid: + "drop up cycle" 1.5em + "left swap right" 1.5em + ". down . " 1.5em + / 1.5em 1.5em 1.5em + ; + gap: 0.5em; +} +.input-action { + padding: 0.25em; + line-height: 1; + color: #fff4; + background: #202020; +} +.input-action[data-action=up] { grid-area: up; } +.input-action[data-action=down] { grid-area: down; } +.input-action[data-action=left] { grid-area: left; } +.input-action[data-action=right] { grid-area: right; } +.input-action[data-action=swap] { grid-area: swap; } +.input-action[data-action=cycle] { grid-area: cycle; } +.input-action[data-action=drop] { grid-area: drop; } +.input-action.--pressed { color: white; - text-align: center; - font-weight: bold; - text-shadow: 0 2px 1px black; -} -.bummer:empty { - display: none; + background: hsl(215, 75%, 25%); }