Fixed several issues with animation and movement; quick stab at replay UI

- Animation now has its own timer and isn't linked to movement cooldown,
  which is good for blocks since they don't have movement cooldown

- Destroyed actors don't crash the game again (oops)

- Slide and cooldown handling was reshuffled to better support the CC2
  approach of landing on tiles with a delay; in particular, you move at
  double speed on sliding tiles again!

- Demo playback got some rough UI so I don't have to keep editing the
  source code to decide whether to play a demo
This commit is contained in:
Eevee (Evelyn Woods) 2020-09-03 09:46:37 -06:00
parent bee6ba4c80
commit 2df8607243
4 changed files with 149 additions and 79 deletions

View File

@ -39,13 +39,15 @@ class Tile {
visual_position(tic_offset = 0) { visual_position(tic_offset = 0) {
let x = this.cell.x; let x = this.cell.x;
let y = this.cell.y; let y = this.cell.y;
if (! this.movement_cooldown) { if (! this.animation_speed) {
return [x, y]; return [x, y];
} }
else { else {
let p = (- this.movement_cooldown + tic_offset) / this.movement_speed; let p = (this.animation_progress + tic_offset) / this.animation_speed;
let motion = DIRECTIONS[this.direction].movement; return [
return [x + p * motion[0], y + p * motion[1]]; (1 - p) * this.previous_cell.x + p * x,
(1 - p) * this.previous_cell.y + p * y,
];
} }
} }
@ -299,18 +301,35 @@ class Level {
// XXX this entire turn order is rather different in ms rules // XXX this entire turn order is rather different in ms rules
for (let actor of this.actors) { for (let actor of this.actors) {
// Actors with no cell were destroyed
if (! actor.cell)
continue;
// Decrement the cooldown here, but only check it later because
// stepping on cells in the next block might reset it
if (actor.movement_cooldown > 0) { if (actor.movement_cooldown > 0) {
this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1); this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1);
if (actor.movement_cooldown > 0) { }
continue;
} if (actor.animation_speed) {
else if (! this.compat.tiles_react_instantly) { // Deal with movement animation
// For delayed arrival (usually paired with smooth actor.animation_progress += 1;
// scrolling), tiles only react once actors finish moving if (actor.animation_progress >= actor.animation_speed) {
// onto them actor.previous_cell = null;
this.step_on_cell(actor); actor.animation_progress = null;
actor.animation_speed = null;
if (! this.compat.tiles_react_instantly) {
this.step_on_cell(actor);
// May have been destroyed here, too!
if (! actor.cell)
continue;
}
} }
} }
if (actor.movement_cooldown > 0)
continue;
// XXX does the cooldown drop while in a trap? is this even right? // XXX does the cooldown drop while in a trap? is this even right?
// TODO should still attempt to move (so chip turns), just will be stuck (but wait, do monsters turn? i don't think so) // TODO should still attempt to move (so chip turns), just will be stuck (but wait, do monsters turn? i don't think so)
if (actor.stuck) if (actor.stuck)
@ -432,17 +451,6 @@ class Level {
} }
} }
// Only set movement cooldown if we actually moved!
if (moved) {
// Speed multiplier is based on the tile we landed /on/.
let speed = actor.type.movement_speed;
if (actor.slide_mode !== null) {
speed /= 2;
}
this._set_prop(actor, 'movement_cooldown', speed);
this._set_prop(actor, 'movement_speed', speed);
}
// TODO do i need to do this more aggressively? // TODO do i need to do this more aggressively?
if (this.state === 'success' || this.state === 'failure') if (this.state === 'success' || this.state === 'failure')
break; break;
@ -466,15 +474,38 @@ class Level {
} }
} }
// Strip out any destroyed actors from the acting order
let p = 0;
for (let i = 0, l = this.actors.length; i < l; i++) {
let actor = this.actors[i];
if (actor.cell) {
if (p !== i) {
this.actors[p] = actor;
}
p++;
}
}
this.actors.length = p;
// Commit the undo state at the end of each tic // Commit the undo state at the end of each tic
this.commit(); this.commit();
} }
// Try to move the given actor one tile in the given direction and update // Try to move the given actor one tile in the given direction and update
// their cooldown. Return true if successful. // their cooldown. Return true if successful.
attempt_step(actor, direction) { attempt_step(actor, direction, speed = null) {
// If speed is given, we're being pushed by something so we're using
// its speed. Otherwise, use our movement speed. If we're moving onto
// a sliding tile, we'll halve it later
let check_for_slide = false;
if (speed === null) {
speed = actor.type.movement_speed;
check_for_slide = true;
}
let move = DIRECTIONS[direction].movement; let move = DIRECTIONS[direction].movement;
let original_cell = actor.cell; let original_cell = actor.cell;
if (!original_cell) console.error(actor);
let goal_x = original_cell.x + move[0]; let goal_x = original_cell.x + move[0];
let goal_y = original_cell.y + move[1]; let goal_y = original_cell.y + move[1];
@ -500,11 +531,16 @@ class Level {
if (! blocked) { if (! blocked) {
let goal_cell = this.cells[goal_y][goal_x]; let goal_cell = this.cells[goal_y][goal_x];
for (let tile of Array.from(goal_cell)) { for (let tile of Array.from(goal_cell)) {
if (check_for_slide && tile.type.slide_mode) {
check_for_slide = false;
speed /= 2;
}
if (! tile.blocks(actor, direction)) if (! tile.blocks(actor, direction))
continue; continue;
if (actor.type.pushes && actor.type.pushes[tile.type.name]) { if (actor.type.pushes && actor.type.pushes[tile.type.name]) {
if (this.attempt_step(tile, direction)) if (this.attempt_step(tile, direction, speed))
// It moved out of the way! // It moved out of the way!
continue; continue;
} }
@ -533,21 +569,28 @@ class Level {
} }
// We're clear! // We're clear!
this.move_to(actor, goal_x, goal_y); this.move_to(actor, goal_x, goal_y, speed);
// Set movement cooldown since we just moved
this._set_prop(actor, 'movement_cooldown', speed);
return true; return true;
} }
// Move the given actor to the given position and perform any appropriate // Move the given actor to the given position and perform any appropriate
// tile interactions. Does NOT check for whether the move is actually // tile interactions. Does NOT check for whether the move is actually
// legal; use attempt_step for that! // legal; use attempt_step for that!
move_to(actor, x, y) { move_to(actor, x, y, speed) {
let original_cell = actor.cell; let original_cell = actor.cell;
if (x === original_cell.x && y === original_cell.y) if (x === original_cell.x && y === original_cell.y)
return; return;
actor.previous_cell = actor.cell;
actor.animation_speed = speed;
actor.animation_progress = 0;
let goal_cell = this.cells[y][x]; let goal_cell = this.cells[y][x];
this.remove_tile(actor); this.remove_tile(actor);
actor.slide_mode = null; this.make_slide(actor, null);
this.add_tile(actor, goal_cell); this.add_tile(actor, goal_cell);
// Announce we're leaving, for the handful of tiles that care about it // Announce we're leaving, for the handful of tiles that care about it
@ -562,11 +605,13 @@ class Level {
} }
} }
// Check for hitting a monster, which is always instant and ends the // Check for a couple effects that always apply immediately
// game right here
// TODO i guess this covers blocks too // TODO i guess this covers blocks too
// TODO do blocks smash monsters? // TODO do blocks smash monsters?
for (let tile of goal_cell) { for (let tile of goal_cell) {
if (tile.type.slide_mode) {
this.make_slide(actor, tile.type.slide_mode);
}
if ((actor.type.is_player && tile.type.is_monster) || if ((actor.type.is_player && tile.type.is_monster) ||
(actor.type.is_monster && tile.type.is_player)) (actor.type.is_monster && tile.type.is_player))
{ {
@ -609,6 +654,7 @@ class Level {
} }
// Handle teleporting, now that the dust has cleared // Handle teleporting, now that the dust has cleared
// FIXME something funny happening here, your input isn't ignore while walking out of it?
let current_cell = actor.cell; let current_cell = actor.cell;
if (teleporter) { if (teleporter) {
let goal = teleporter.connection; let goal = teleporter.connection;
@ -759,6 +805,9 @@ class Overlay {
} }
open() { open() {
// FIXME ah, but keystrokes can still go to the game, including
// spacebar to begin it if it was waiting. how do i completely disable
// an entire chunk of the page?
if (this.game.state === 'playing') { if (this.game.state === 'playing') {
this.game.set_state('paused'); this.game.set_state('paused');
} }
@ -990,20 +1039,18 @@ const GAME_UI_HTML = `
</div> </div>
<div class="inventory"></div> <div class="inventory"></div>
<div class="controls"> <div class="controls">
<button class="control-pause" type="button">Pause</button> <div class="play-controls">
<button class="control-restart" type="button">Restart</button> <button class="control-pause" type="button">Pause</button>
<button class="control-undo" type="button">Undo</button> <button class="control-restart" type="button">Restart</button>
<button class="control-rewind" type="button">Rewind</button> <button class="control-undo" type="button">Undo</button>
</div> <button class="control-rewind" type="button">Rewind</button>
<div class="demo"> </div>
<h2>Solution demo available</h2>
<div class="demo-controls"> <div class="demo-controls">
<button class="demo-play" type="button">Restart and play</button> <button class="demo-play" type="button">View replay</button>
<button class="demo-step-1" type="button">Step 1 tic</button> <button class="demo-step-1" type="button">Step 1 tic</button>
<button class="demo-step-4" type="button">Step 1 move</button> <button class="demo-step-4" type="button">Step 1 move</button>
<button class="demo-step-20" type="button">Step 1 second</button> <div class="input"></div>
</div> </div>
<div class="input"></div>
</div> </div>
</main> </main>
`; `;
@ -1125,14 +1172,23 @@ class Game {
ev.target.blur(); ev.target.blur();
}); });
// Demo playback // Demo playback
this.container.querySelector('.demo .demo-step-1').addEventListener('click', ev => { this.container.querySelector('.demo-controls .demo-play').addEventListener('click', ev => {
if (this.state === 'playing' || this.state === 'paused' || this.state === 'rewinding') {
new ConfirmOverlay(this, "Abandon your progress and watch the replay?", () => {
this.play_demo();
});
}
else {
this.play_demo();
}
});
this.container.querySelector('.demo-controls .demo-step-1').addEventListener('click', ev => {
this.advance_by(1); this.advance_by(1);
this._redraw();
}); });
this.container.querySelector('.demo .demo-step-4').addEventListener('click', ev => { this.container.querySelector('.demo-controls .demo-step-4').addEventListener('click', ev => {
this.advance_by(4); this.advance_by(4);
}); this._redraw();
this.container.querySelector('.demo .demo-step-20').addEventListener('click', ev => {
this.advance_by(20);
}); });
// Populate inventory // Populate inventory
@ -1224,17 +1280,6 @@ class Game {
// Done with UI, now we can load a level // Done with UI, now we can load a level
this.load_level(0); this.load_level(0);
if (false && this.level.stored_level.demo) {
this.demo = this.level.stored_level.demo[Symbol.iterator]();
// FIXME should probably start playback on first input
this.set_state('playing');
}
else {
// TODO update these, as appropriate, when loading a level
this.input_el.style.display = 'none';
this.demo_el.style.display = 'none';
}
// Auto-size the level canvas, both now and on resize // Auto-size the level canvas, both now and on resize
this.adjust_scale(); this.adjust_scale();
window.addEventListener('resize', ev => { window.addEventListener('resize', ev => {
@ -1265,6 +1310,9 @@ class Game {
this.nav_prev_button.disabled = level_index <= 0; this.nav_prev_button.disabled = level_index <= 0;
this.nav_next_button.disabled = level_index >= this.stored_game.levels.length; this.nav_next_button.disabled = level_index >= this.stored_game.levels.length;
this.demo_faucet = null;
this.container.classList.toggle('--has-demo', !!this.level.stored_level.demo);
this.update_ui(); this.update_ui();
// Force a redraw, which won't happen on its own since the game isn't running // Force a redraw, which won't happen on its own since the game isn't running
this._redraw(); this._redraw();
@ -1274,11 +1322,19 @@ class Game {
this.level.restart(this.compat); this.level.restart(this.compat);
this.set_state('waiting'); this.set_state('waiting');
this.update_ui(); this.update_ui();
this._redraw();
}
play_demo() {
this.demo_faucet = this.level.stored_level.demo[Symbol.iterator]();
this.restart_level();
// FIXME should probably start playback on first real input
this.set_state('playing');
} }
get_input() { get_input() {
if (this.demo) { if (this.demo_faucet) {
let step = this.demo.next(); let step = this.demo_faucet.next();
if (step.done) { if (step.done) {
return new Set; return new Set;
} }

View File

@ -487,7 +487,7 @@ export class Tileset {
// TODO this is getting really ad-hoc and clumsy lol, maybe // TODO this is getting really ad-hoc and clumsy lol, maybe
// have tiles expose a single 'state' prop or something // have tiles expose a single 'state' prop or something
if (coords.moving) { if (coords.moving) {
if (tile.movement_cooldown) { if (tile.animation_speed) {
coords = coords.moving; coords = coords.moving;
} }
else { else {

View File

@ -150,12 +150,11 @@ const TILE_TYPES = {
turtle: { turtle: {
}, },
ice: { ice: {
on_arrive(me, level, other) { slide_mode: 'ice',
level.make_slide(other, 'ice');
},
}, },
ice_sw: { ice_sw: {
thin_walls: new Set(['south', 'west']), thin_walls: new Set(['south', 'west']),
slide_mode: 'ice',
on_arrive(me, level, other) { on_arrive(me, level, other) {
if (other.direction === 'south') { if (other.direction === 'south') {
other.direction = 'east'; other.direction = 'east';
@ -163,11 +162,11 @@ const TILE_TYPES = {
else { else {
other.direction = 'north'; other.direction = 'north';
} }
level.make_slide(other, 'ice');
}, },
}, },
ice_nw: { ice_nw: {
thin_walls: new Set(['north', 'west']), thin_walls: new Set(['north', 'west']),
slide_mode: 'ice',
on_arrive(me, level, other) { on_arrive(me, level, other) {
if (other.direction === 'north') { if (other.direction === 'north') {
other.direction = 'east'; other.direction = 'east';
@ -175,11 +174,11 @@ const TILE_TYPES = {
else { else {
other.direction = 'south'; other.direction = 'south';
} }
level.make_slide(other, 'ice');
}, },
}, },
ice_ne: { ice_ne: {
thin_walls: new Set(['north', 'east']), thin_walls: new Set(['north', 'east']),
slide_mode: 'ice',
on_arrive(me, level, other) { on_arrive(me, level, other) {
if (other.direction === 'north') { if (other.direction === 'north') {
other.direction = 'west'; other.direction = 'west';
@ -187,11 +186,11 @@ const TILE_TYPES = {
else { else {
other.direction = 'south'; other.direction = 'south';
} }
level.make_slide(other, 'ice');
}, },
}, },
ice_se: { ice_se: {
thin_walls: new Set(['south', 'east']), thin_walls: new Set(['south', 'east']),
slide_mode: 'ice',
on_arrive(me, level, other) { on_arrive(me, level, other) {
if (other.direction === 'south') { if (other.direction === 'south') {
other.direction = 'west'; other.direction = 'west';
@ -199,38 +198,37 @@ const TILE_TYPES = {
else { else {
other.direction = 'north'; other.direction = 'north';
} }
level.make_slide(other, 'ice');
}, },
}, },
force_floor_n: { force_floor_n: {
slide_mode: 'force',
on_arrive(me, level, other) { on_arrive(me, level, other) {
other.direction = 'north'; other.direction = 'north';
level.make_slide(other, 'force');
}, },
}, },
force_floor_e: { force_floor_e: {
slide_mode: 'force',
on_arrive(me, level, other) { on_arrive(me, level, other) {
other.direction = 'east'; other.direction = 'east';
level.make_slide(other, 'force');
}, },
}, },
force_floor_s: { force_floor_s: {
slide_mode: 'force',
on_arrive(me, level, other) { on_arrive(me, level, other) {
other.direction = 'south'; other.direction = 'south';
level.make_slide(other, 'force');
}, },
}, },
force_floor_w: { force_floor_w: {
slide_mode: 'force',
on_arrive(me, level, other) { on_arrive(me, level, other) {
other.direction = 'west'; other.direction = 'west';
level.make_slide(other, 'force');
}, },
}, },
force_floor_all: { force_floor_all: {
slide_mode: 'force',
// TODO ms: this is random, and an acting wall to monsters (!) // TODO ms: this is random, and an acting wall to monsters (!)
on_arrive(me, level, other) { on_arrive(me, level, other) {
other.direction = level.get_force_floor_direction(); other.direction = level.get_force_floor_direction();
level.make_slide(other, 'force');
}, },
}, },
bomb: { bomb: {
@ -325,7 +323,6 @@ const TILE_TYPES = {
connects_to: 'teleport_blue', connects_to: 'teleport_blue',
connect_order: 'backward', connect_order: 'backward',
is_teleporter: true, is_teleporter: true,
// TODO implement 'backward'
// TODO to make this work, i need to be able to check if a spot is blocked /ahead of time/ // TODO to make this work, i need to be able to check if a spot is blocked /ahead of time/
}, },
// Buttons // Buttons
@ -565,6 +562,14 @@ const TILE_TYPES = {
chip_extra: { chip_extra: {
is_chip: true, is_chip: true,
is_object: true, is_object: true,
blocks_monsters: true,
blocks_blocks: true,
on_arrive(me, level, other) {
if (other.type.is_player) {
level.collect_chip();
level.remove_tile(me);
}
},
}, },
score_10: { score_10: {
is_object: true, is_object: true,

View File

@ -152,9 +152,8 @@ main {
"level time" "level time"
"level bonus" "level bonus"
"level message" 1fr "level message" 1fr
"level inventory" "level inventory"
"level controls" "controls controls"
"demo demo"
/* Need explicit min-content to force the hint to wrap */ /* Need explicit min-content to force the hint to wrap */
/ min-content min-content / min-content min-content
; ;
@ -319,18 +318,28 @@ dl.score-chart .-sum {
.controls { .controls {
grid-area: controls; grid-area: controls;
display: flex; display: flex;
}
.play-controls,
.demo-controls {
display: flex;
gap: 0.25em; gap: 0.25em;
} }
.controls > button { .play-controls {
flex: 1; align-self: start;
} }
.demo { .demo-controls {
grid-area: demo; display: none;
flex: 1;
justify-content: end;
}
main.--has-demo .demo-controls {
display: flex;
} }
/* Debug stuff */ /* Debug stuff */
.input { .input {
display: grid; display: grid;
display: none;
grid: grid:
"drop up cycle" 1.5em "drop up cycle" 1.5em
"left swap right" 1.5em "left swap right" 1.5em