diff --git a/index.html b/index.html index 04e3b97..9bd8a22 100644 --- a/index.html +++ b/index.html @@ -56,6 +56,7 @@

+

diff --git a/js/game.js b/js/game.js index 7974ab3..e0c6054 100644 --- a/js/game.js +++ b/js/game.js @@ -745,6 +745,7 @@ export class Level { if (actor.cell === goal_cell) return; + // TODO undo this stuff? actor.previous_cell = actor.cell; actor.animation_speed = speed; actor.animation_progress = 0; diff --git a/js/main.js b/js/main.js index 309d735..0b2e043 100644 --- a/js/main.js +++ b/js/main.js @@ -268,6 +268,12 @@ class Player extends PrimaryView { this._redraw(); ev.target.blur(); }); + this.rewind_button = this.root.querySelector('.controls .control-rewind'); + this.rewind_button.addEventListener('click', ev => { + if (this.level.undo_stack.length > 0) { + this.state = 'rewinding'; + } + }); // Demo playback this.root.querySelector('.demo-controls .demo-play').addEventListener('click', ev => { if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') { @@ -339,6 +345,14 @@ class Player extends PrimaryView { ev.preventDefault(); } + if (ev.key === 'z') { + if (this.level.undo_stack.length > 0 && + (this.state === 'stopped' || this.state === 'playing' || this.state === 'paused')) + { + this.set_state('rewinding'); + } + } + if (this.key_mapping[ev.key]) { this.current_keys.add(ev.key); ev.stopPropagation(); @@ -350,6 +364,12 @@ class Player extends PrimaryView { } }); key_target.addEventListener('keyup', ev => { + if (ev.key === 'z') { + if (this.state === 'rewinding') { + this.set_state('playing'); + } + } + if (this.key_mapping[ev.key]) { this.current_keys.delete(ev.key); ev.stopPropagation(); @@ -357,6 +377,15 @@ class Player extends PrimaryView { } }); + // When we lose focus, act as though every key was released, and pause the game + window.addEventListener('blur', ev => { + this.current_keys.clear(); + + if (this.state === 'playing' || this.state === 'rewinding') { + this.set_state('paused'); + } + }); + // Populate input debugger this.input_el = this.root.querySelector('.input'); this.input_action_elements = {}; @@ -366,13 +395,6 @@ class Player extends PrimaryView { this.input_action_elements[action] = el; } - // Auto pause when we lose focus - window.addEventListener('blur', ev => { - if (this.state === 'playing' || this.state === 'rewinding') { - this.set_state('paused'); - } - }); - this._advance_bound = this.advance.bind(this); this._redraw_bound = this.redraw.bind(this); // Used to determine where within a tic we are, for animation purposes @@ -554,7 +576,22 @@ class Player extends PrimaryView { } this.last_advance = performance.now(); - this.advance_by(1); + if (this.state === 'playing') { + this.advance_by(1); + } + else if (this.state === 'rewinding') { + if (this.level.undo_stack.length === 0) { + // TODO detect if we hit the start of the level (rather than just running the undo + // buffer dry) and change to 'waiting' instead + // TODO pausing seems rude actually, it should just hover in-place? + this._advance_handle = null; + this.set_state('paused'); + } + else { + // Rewind by undoing one tic every tic + this.level.undo(); + } + } this._advance_handle = window.setTimeout(this._advance_bound, 1000 / TICS_PER_SECOND); } @@ -566,6 +603,9 @@ class Player extends PrimaryView { // TODO i'm not sure it'll be right when rewinding either // TODO or if the game's speed changes. wow! this.tic_offset = Math.min(0.9999, (performance.now() - this.last_advance) / 1000 / (1 / TICS_PER_SECOND)); + if (this.state === 'rewinding') { + this.tic_offset = 1 - this.tic_offset; + } this._redraw(); @@ -637,7 +677,7 @@ class Player extends PrimaryView { if (this.state === 'paused') { this.set_state('playing'); } - else if (this.state === 'playing') { + else if (this.state === 'playing' || this.state === 'rewinding') { this.set_state('paused'); } } @@ -653,6 +693,7 @@ class Player extends PrimaryView { let overlay_top = ''; let overlay_middle = null; let overlay_bottom = ''; + let overlay_keyhint = ''; if (this.state === 'waiting') { overlay_reason = 'waiting'; overlay_middle = "Ready!"; @@ -660,6 +701,7 @@ class Player extends PrimaryView { else if (this.state === 'paused') { overlay_reason = 'paused'; overlay_bottom = "/// paused ///"; + overlay_keyhint = "press P to resume"; } else if (this.state === 'stopped') { if (this.level.state === 'failure') { @@ -667,6 +709,7 @@ class Player extends PrimaryView { overlay_top = "whoops"; let obits = OBITUARIES[this.level.fail_reason] ?? OBITUARIES['generic']; overlay_bottom = random_choice(obits); + overlay_keyhint = "press space to try again, or Z to rewind"; } else { overlay_reason = 'success'; @@ -706,7 +749,7 @@ class Player extends PrimaryView { "alphanumeric!", "nice dynamic typing!", ]); } - overlay_bottom = "press spacebar to continue"; + overlay_keyhint = "press space to move on"; overlay_middle = mk('dl.score-chart', mk('dt', "base score"), @@ -727,12 +770,22 @@ class Player extends PrimaryView { this.overlay_message_el.setAttribute('data-reason', overlay_reason); this.overlay_message_el.querySelector('.-top').textContent = overlay_top; this.overlay_message_el.querySelector('.-bottom').textContent = overlay_bottom; + this.overlay_message_el.querySelector('.-keyhint').textContent = overlay_keyhint; let middle = this.overlay_message_el.querySelector('.-middle'); middle.textContent = ''; if (overlay_middle) { middle.append(overlay_middle); } + // Ask the renderer to apply a rewind effect only when rewinding, or when paused from + // rewinding + if (this.state === 'rewinding') { + this.renderer.use_rewind_effect = true; + } + else if (this.state !== 'paused') { + this.renderer.use_rewind_effect = false; + } + // The advance and redraw methods run in a loop, but they cancel // themselves if the game isn't running, so restart them here if (this.state === 'playing' || this.state === 'rewinding') { diff --git a/js/renderer-canvas.js b/js/renderer-canvas.js index 577d840..3ad5f63 100644 --- a/js/renderer-canvas.js +++ b/js/renderer-canvas.js @@ -25,6 +25,7 @@ export class CanvasRenderer { this.ctx = this.canvas.getContext('2d'); this.viewport_x = 0; this.viewport_y = 0; + this.use_rewind_effect = false; } set_level(level) { @@ -129,6 +130,24 @@ export class CanvasRenderer { } } } + + if (this.use_rewind_effect) { + this.draw_rewind_effect(tic); + } + } + + draw_rewind_effect(tic) { + // Shift several rows over + let rewind_start = 1 - tic / 20 % 1; + for (let chunk = 0; chunk < 4; chunk++) { + let y = Math.floor(this.canvas.height * (chunk + rewind_start) / 4); + for (let dy = 1; dy < 5; dy++) { + this.ctx.drawImage( + this.canvas, + 0, y + dy, this.canvas.width, 1, + -dy * dy, y + dy, this.canvas.width, 1); + } + } } create_tile_type_canvas(name) { diff --git a/style.css b/style.css index 5bea114..cc38b1f 100644 --- a/style.css +++ b/style.css @@ -328,13 +328,13 @@ body[data-mode=player] #editor-play { place-self: stretch; display: grid; - grid-template-rows: 1fr 3fr 1fr; + grid-template-rows: 2fr 6fr 2fr 1fr; justify-content: center; align-items: center; z-index: 1; font-size: calc(0.5 * var(--tile-width) * var(--scale)); - padding: 6.25%; + padding: 2%; background: #0009; color: white; text-align: center; @@ -350,6 +350,11 @@ body[data-mode=player] #editor-play { } #player .overlay-message .-bottom { } +#player .overlay-message .-keyhint { + align-self: end; + font-size: 0.5em; + color: #c0c0c0; +} #player .overlay-message[data-reason=""] { display: none; }