Implement rewind, add a key for it, and suggest keys in general

This commit is contained in:
Eevee (Evelyn Woods) 2020-09-16 19:40:38 -06:00
parent 57810da581
commit 063d9f9ef9
5 changed files with 91 additions and 12 deletions

View File

@ -56,6 +56,7 @@
<h1 class="-top"></h1> <h1 class="-top"></h1>
<div class="-middle"></div> <div class="-middle"></div>
<p class="-bottom"></p> <p class="-bottom"></p>
<p class="-keyhint"></p>
</div> </div>
<div class="message"></div> <div class="message"></div>
<div class="chips"> <div class="chips">

View File

@ -745,6 +745,7 @@ export class Level {
if (actor.cell === goal_cell) if (actor.cell === goal_cell)
return; return;
// TODO undo this stuff?
actor.previous_cell = actor.cell; actor.previous_cell = actor.cell;
actor.animation_speed = speed; actor.animation_speed = speed;
actor.animation_progress = 0; actor.animation_progress = 0;

View File

@ -268,6 +268,12 @@ class Player extends PrimaryView {
this._redraw(); this._redraw();
ev.target.blur(); 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 // Demo playback
this.root.querySelector('.demo-controls .demo-play').addEventListener('click', ev => { this.root.querySelector('.demo-controls .demo-play').addEventListener('click', ev => {
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') { if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
@ -339,6 +345,14 @@ class Player extends PrimaryView {
ev.preventDefault(); 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]) { if (this.key_mapping[ev.key]) {
this.current_keys.add(ev.key); this.current_keys.add(ev.key);
ev.stopPropagation(); ev.stopPropagation();
@ -350,6 +364,12 @@ class Player extends PrimaryView {
} }
}); });
key_target.addEventListener('keyup', ev => { key_target.addEventListener('keyup', ev => {
if (ev.key === 'z') {
if (this.state === 'rewinding') {
this.set_state('playing');
}
}
if (this.key_mapping[ev.key]) { if (this.key_mapping[ev.key]) {
this.current_keys.delete(ev.key); this.current_keys.delete(ev.key);
ev.stopPropagation(); 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 // Populate input debugger
this.input_el = this.root.querySelector('.input'); this.input_el = this.root.querySelector('.input');
this.input_action_elements = {}; this.input_action_elements = {};
@ -366,13 +395,6 @@ class Player extends PrimaryView {
this.input_action_elements[action] = el; 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._advance_bound = this.advance.bind(this);
this._redraw_bound = this.redraw.bind(this); this._redraw_bound = this.redraw.bind(this);
// Used to determine where within a tic we are, for animation purposes // 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.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); 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 i'm not sure it'll be right when rewinding either
// TODO or if the game's speed changes. wow! // 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)); 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(); this._redraw();
@ -637,7 +677,7 @@ class Player extends PrimaryView {
if (this.state === 'paused') { if (this.state === 'paused') {
this.set_state('playing'); this.set_state('playing');
} }
else if (this.state === 'playing') { else if (this.state === 'playing' || this.state === 'rewinding') {
this.set_state('paused'); this.set_state('paused');
} }
} }
@ -653,6 +693,7 @@ class Player extends PrimaryView {
let overlay_top = ''; let overlay_top = '';
let overlay_middle = null; let overlay_middle = null;
let overlay_bottom = ''; let overlay_bottom = '';
let overlay_keyhint = '';
if (this.state === 'waiting') { if (this.state === 'waiting') {
overlay_reason = 'waiting'; overlay_reason = 'waiting';
overlay_middle = "Ready!"; overlay_middle = "Ready!";
@ -660,6 +701,7 @@ class Player extends PrimaryView {
else if (this.state === 'paused') { else if (this.state === 'paused') {
overlay_reason = 'paused'; overlay_reason = 'paused';
overlay_bottom = "/// paused ///"; overlay_bottom = "/// paused ///";
overlay_keyhint = "press P to resume";
} }
else if (this.state === 'stopped') { else if (this.state === 'stopped') {
if (this.level.state === 'failure') { if (this.level.state === 'failure') {
@ -667,6 +709,7 @@ class Player extends PrimaryView {
overlay_top = "whoops"; overlay_top = "whoops";
let obits = OBITUARIES[this.level.fail_reason] ?? OBITUARIES['generic']; let obits = OBITUARIES[this.level.fail_reason] ?? OBITUARIES['generic'];
overlay_bottom = random_choice(obits); overlay_bottom = random_choice(obits);
overlay_keyhint = "press space to try again, or Z to rewind";
} }
else { else {
overlay_reason = 'success'; overlay_reason = 'success';
@ -706,7 +749,7 @@ class Player extends PrimaryView {
"alphanumeric!", "nice dynamic typing!", "alphanumeric!", "nice dynamic typing!",
]); ]);
} }
overlay_bottom = "press spacebar to continue"; overlay_keyhint = "press space to move on";
overlay_middle = mk('dl.score-chart', overlay_middle = mk('dl.score-chart',
mk('dt', "base score"), 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.setAttribute('data-reason', overlay_reason);
this.overlay_message_el.querySelector('.-top').textContent = overlay_top; this.overlay_message_el.querySelector('.-top').textContent = overlay_top;
this.overlay_message_el.querySelector('.-bottom').textContent = overlay_bottom; 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'); let middle = this.overlay_message_el.querySelector('.-middle');
middle.textContent = ''; middle.textContent = '';
if (overlay_middle) { if (overlay_middle) {
middle.append(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 // The advance and redraw methods run in a loop, but they cancel
// themselves if the game isn't running, so restart them here // themselves if the game isn't running, so restart them here
if (this.state === 'playing' || this.state === 'rewinding') { if (this.state === 'playing' || this.state === 'rewinding') {

View File

@ -25,6 +25,7 @@ export class CanvasRenderer {
this.ctx = this.canvas.getContext('2d'); this.ctx = this.canvas.getContext('2d');
this.viewport_x = 0; this.viewport_x = 0;
this.viewport_y = 0; this.viewport_y = 0;
this.use_rewind_effect = false;
} }
set_level(level) { 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) { create_tile_type_canvas(name) {

View File

@ -328,13 +328,13 @@ body[data-mode=player] #editor-play {
place-self: stretch; place-self: stretch;
display: grid; display: grid;
grid-template-rows: 1fr 3fr 1fr; grid-template-rows: 2fr 6fr 2fr 1fr;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
z-index: 1; z-index: 1;
font-size: calc(0.5 * var(--tile-width) * var(--scale)); font-size: calc(0.5 * var(--tile-width) * var(--scale));
padding: 6.25%; padding: 2%;
background: #0009; background: #0009;
color: white; color: white;
text-align: center; text-align: center;
@ -350,6 +350,11 @@ body[data-mode=player] #editor-play {
} }
#player .overlay-message .-bottom { #player .overlay-message .-bottom {
} }
#player .overlay-message .-keyhint {
align-self: end;
font-size: 0.5em;
color: #c0c0c0;
}
#player .overlay-message[data-reason=""] { #player .overlay-message[data-reason=""] {
display: none; display: none;
} }