Compare commits

...

1 Commits

Author SHA1 Message Date
Eevee (Evelyn Woods)
86764612d3 [WIP] Switch to a more accurate frame-based model
This seems to match how CC2 actually works, and it fixes the replays for
the CC1 levels BLOCK BUSTER, THE PRISONER, CATACOMBS, and GOLDKEY.

Unfortunately, it also regresses ON THE ROCKS, GRAIL, and ALPHABET SOUP,
and I do not know why.  I'm not even sure why it fixes CATACOMBS and
GOLDKEY.  It makes a mess of force floor handling with mixed results.
It's also some 40% slower, which is not ideal.

I doubt I'll revisit this particular branch, since it's the sort of mass
code rearrangement that breaks everything, but I'm keeping it for future
reference in case I try to revisit this idea.
2020-12-15 21:07:47 -07:00
2 changed files with 366 additions and 156 deletions

View File

@ -557,23 +557,91 @@ export class Level {
this.pending_undo.level_props[key] = this[key];
}
this.p1_input = p1_input;
// Used for various tic-local effects; don't need to be undoable
// TODO maybe this should be undone anyway so rewind looks better?
this.player.is_blocked = false;
this.sfx.set_player_position(this.player.cell);
// CC2 wiring runs every frame, not every tic, so we need to do it three times, but dealing
// with it is delicate. We want the result of a button press to draw, but not last longer
// than intended, so we only want one update between the end of the cooldown pass and the
// end of the tic. That means the other two have to go here. When a level starts, there
// are only two wiring updates before everything gets its first chance to move, so we skip
// the very first one here.
if (this.tic_counter !== 0) {
this._do_decision_pass(false);
this._do_action_pass();
this._do_cooldown_pass();
this.update_wiring();
this._do_decision_pass(false);
this._do_action_pass();
this._do_cooldown_pass();
this.update_wiring();
}
finish_tic(p1_input) {
this.p1_input = p1_input;
this._do_decision_pass(true);
// In the event that the player is sliding (and thus not deliberately moving) or has
// stopped, remember their current movement direction here, too.
// This is hokey, and doing it here is even hokier, but it seems to match CC2 behavior.
if (this.player.movement_cooldown > 0) {
this.remember_player_move(this.player.direction);
}
this._do_action_pass();
this._do_cooldown_pass();
this.update_wiring();
// Strip out any destroyed actors from the acting order
// FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly
// necessary, either; maybe only do it every few tics?
let p = 0;
for (let i = 0, l = this.actors.length; i < l; i++) {
let actor = this.actors[i];
// While we're here, delete this guy
delete actor.cooldown_delay_hack;
if (actor.cell) {
if (p !== i) {
this.actors[p] = actor;
}
p++;
}
else {
let local_p = p;
this.pending_undo.push(() => this.actors.splice(local_p, 0, actor));
}
}
this.actors.length = p;
// Possibly switch players
// FIXME not correct
/*
if (swap_player1) {
this.player_index += 1;
this.player_index %= this.players.length;
this.player = this.players[this.player_index];
}
*/
this.previous_input = this.p1_input;
// Advance the clock
// TODO i suspect cc2 does this at the beginning of the tic, but even if you've won? if you
// step on a penalty + exit you win, but you see the clock flicker 1 for a single frame
this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) {
this.time_remaining -= 1;
if (this.time_remaining <= 0) {
this.fail('time');
}
else if (this.time_remaining % 20 === 0 && this.time_remaining < 30 * 20) {
this.sfx.play_once('tick');
}
}
this.commit();
}
_do_cooldown_pass() {
// FIRST PASS: actors tick their cooldowns, finish their movement, and possibly step on
// cells they were moving into. This has a few advantages: it makes rendering interpolation
// much easier, and doing it as a separate pass from /starting/ movement (unlike Lynx)
@ -631,146 +699,9 @@ export class Level {
continue;
if (actor.just_stepped_on_teleporter) {
this.attempt_teleport(actor, actor === this.player ? p1_input : null);
this.attempt_teleport(actor, actor === this.player ? this.p1_input : null);
}
}
// Here's the third.
this.update_wiring();
}
finish_tic(p1_input) {
// SECOND PASS: actors decide their upcoming movement simultaneously
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
// Clear any old decisions ASAP. Note that this prop is only used internally within a
// single tic, so it doesn't need to be undoable
actor.decision = null;
if (! actor.cell)
continue;
if (actor.movement_cooldown > 0)
continue;
if (actor === this.player) {
this.make_player_decision(actor, p1_input);
}
else {
this.make_actor_decision(actor);
}
}
// THIRD PASS: everyone actually moves
let swap_player1 = false;
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
if (actor.type.on_tic) {
actor.type.on_tic(actor, this);
}
// Check this again, since an earlier pass may have caused us to start moving
if (actor.movement_cooldown > 0)
continue;
// Check for special player actions, which can only happen when not moving
// FIXME if you press a key while moving it should happen as soon as you stop (assuming
// the key is still held down)
// FIXME cc2 seems to rely on key repeat for this; the above is true, but also, if you
// have four bowling balls and hold Q, you'll throw the first, wait a second or so, then
// release the rest rapid-fire. absurd
if (actor === this.player) {
let new_input = p1_input & ~this.previous_input;
if (new_input & INPUT_BITS.cycle) {
this.cycle_inventory(this.player);
}
if (new_input & INPUT_BITS.drop) {
this.drop_item(this.player);
}
if (new_input & INPUT_BITS.swap) {
// This is delayed until the end of the tic to avoid screwing up anything
// checking this.player
swap_player1 = true;
}
}
if (! actor.decision)
continue;
// Actor is allowed to move, so do so
let old_cell = actor.cell;
let success = this.attempt_step(actor, actor.decision);
if (! success && actor.slide_mode === 'ice') {
this._handle_slide_bonk(actor);
success = this.attempt_step(actor, actor.decision);
}
// Track whether the player is blocked, for visual effect
if (actor === this.player && actor.decision && ! success) {
this.sfx.play_once('blocked');
actor.is_blocked = true;
}
}
// In the event that the player is sliding (and thus not deliberately moving) or has
// stopped, remember their current movement direction here, too.
// This is hokey, and doing it here is even hokier, but it seems to match CC2 behavior.
if (this.player.movement_cooldown > 0) {
this.remember_player_move(this.player.direction);
}
// Strip out any destroyed actors from the acting order
// FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly
// necessary, either; maybe only do it every few tics?
let p = 0;
for (let i = 0, l = this.actors.length; i < l; i++) {
let actor = this.actors[i];
// While we're here, delete this guy
delete actor.cooldown_delay_hack;
if (actor.cell) {
if (p !== i) {
this.actors[p] = actor;
}
p++;
}
else {
let local_p = p;
this.pending_undo.push(() => this.actors.splice(local_p, 0, actor));
}
}
this.actors.length = p;
// Possibly switch players
// FIXME not correct
if (swap_player1) {
this.player_index += 1;
this.player_index %= this.players.length;
this.player = this.players[this.player_index];
}
this.previous_input = p1_input;
// Advance the clock
// TODO i suspect cc2 does this at the beginning of the tic, but even if you've won? if you
// step on a penalty + exit you win, but you see the clock flicker 1 for a single frame
this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) {
this.time_remaining -= 1;
if (this.time_remaining <= 0) {
this.fail('time');
}
else if (this.time_remaining % 20 === 0 && this.time_remaining < 30 * 20) {
this.sfx.play_once('tick');
}
}
this.commit();
}
_extract_player_directions(input) {
@ -834,6 +765,15 @@ export class Level {
if (actor.slide_mode === 'force') {
this._set_tile_prop(actor, 'last_move_was_force', true);
if (! try_direction(actor.direction, 'move') && actor.cell.get_terrain().type.name === 'force_floor_all') {
for (let i = 0; i < 3; i++) {
let direction = this.get_force_floor_direction();
if (try_direction(direction, 'move') || i === 2) {
actor.decision = direction;
break;
}
}
}
}
else if (actor.slide_mode === 'ice') {
// A sliding player that bonks into a wall still needs to turn around, but in this
@ -901,11 +841,23 @@ export class Level {
// If we're overriding a force floor but the direction we're moving in is blocked, the
// force floor takes priority (and we've already bumped the wall(s))
if (actor.slide_mode === 'force' && ! open) {
actor.decision = actor.direction;
this._set_tile_prop(actor, 'last_move_was_force', true);
// Be sure to invoke this hack if we're standing on an RFF
if (actor.cell.get_terrain().type.name === 'force_floor_all') {
for (let i = 0; i < 3; i++) {
let direction = this.get_force_floor_direction();
if (try_direction(direction, 'move') || i === 2) {
actor.decision = direction;
break;
}
}
}
else {
actor.decision = actor.direction;
this._handle_slide_bonk(actor);
}
}
else {
// Otherwise this is 100% a conscious move so we lose our override power next tic
this._set_tile_prop(actor, 'last_move_was_force', false);
@ -978,6 +930,262 @@ export class Level {
}
}
_do_decision_pass(tic_aligned) {
// SECOND PASS: actors decide their upcoming movement simultaneously
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
// Clear any old decisions ASAP. Note that this prop is only used internally within a
// single tic, so it doesn't need to be undoable
actor.decision = null;
if (! actor.cell)
continue;
if (actor.movement_cooldown > 0)
continue;
if (! tic_aligned && ! actor.slide_mode)
continue;
if (actor.slide_mode && actor.cell.get_terrain().type.name === 'force_floor_all') {
this.set_actor_direction(actor, this.get_force_floor_direction());
}
if (actor === this.player) {
this.make_player_decision(actor, this.p1_input);
}
else {
this.make_actor_decision(actor);
}
}
}
_do_action_pass() {
// THIRD PASS: everyone actually moves
let swap_player1 = false;
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
if (actor.type.on_tic) {
actor.type.on_tic(actor, this);
}
// Check this again, since an earlier pass may have caused us to start moving
if (actor.movement_cooldown > 0)
continue;
// Check for special player actions, which can only happen when not moving
// FIXME if you press a key while moving it should happen as soon as you stop (assuming
// the key is still held down)
// FIXME cc2 seems to rely on key repeat for this; the above is true, but also, if you
// have four bowling balls and hold Q, you'll throw the first, wait a second or so, then
// release the rest rapid-fire. absurd
if (actor === this.player) {
let new_input = this.p1_input & ~this.previous_input;
if (new_input & INPUT_BITS.cycle) {
this.cycle_inventory(this.player);
}
if (new_input & INPUT_BITS.drop) {
this.drop_item(this.player);
}
if (new_input & INPUT_BITS.swap) {
// This is delayed until the end of the tic to avoid screwing up anything
// checking this.player
swap_player1 = true;
}
}
if (! actor.decision)
continue;
// Actor is allowed to move, so do so
let old_cell = actor.cell;
let success = this.attempt_step(actor, actor.decision);
if (! success && actor.slide_mode === 'ice') {
this._handle_slide_bonk(actor);
success = this.attempt_step(actor, actor.decision);
}
// Track whether the player is blocked, for visual effect
if (actor === this.player && actor.decision && ! success) {
this.sfx.play_once('blocked');
actor.is_blocked = true;
}
}
}
_do_cooldown_pass() {
// FIRST PASS: actors tick their cooldowns, finish their movement, and possibly step on
// cells they were moving into. This has a few advantages: it makes rendering interpolation
// much easier, and doing it as a separate pass from /starting/ movement (unlike Lynx)
// improves the illusion that everything is happening simultaneously.
// Note that, as far as I can tell, CC2 actually runs this pass every /frame/. We do not!
// Also Note that we iterate in reverse order, DESPITE keeping dead actors around with null
// cells, to match the Lynx and CC2 behavior. This is actually important in some cases;
// check out the start of CCLP3 #54, where the gliders will eat the blue key immediately if
// they act in forward order! (More subtly, even the decision pass does things like
// advance the RNG, so for replay compatibility it needs to be in reverse order too.)
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
// Actors with no cell were destroyed
if (! actor.cell)
continue;
if (actor.movement_cooldown <= 0)
continue;
if (actor.cooldown_delay_hack) {
// See the extensive comment in attempt_out_of_turn_step
actor.cooldown_delay_hack += 1;
continue;
}
this._set_tile_prop(actor, 'movement_cooldown', Math.max(0, actor.movement_cooldown - 1));
if (actor.movement_cooldown <= 0) {
if (actor.type.ttl) {
// This is an animation that just finished, so destroy it
this.remove_tile(actor);
continue;
}
if (! this.compat.tiles_react_instantly) {
this.step_on_cell(actor, actor.cell);
}
// Erase any trace of being in mid-movement, however:
// - This has to happen after stepping on cells, because some effects care about
// the cell we're arriving from
// - Don't do it if stepping on the cell caused us to move again
if (actor.movement_cooldown <= 0) {
this._set_tile_prop(actor, 'previous_cell', null);
this._set_tile_prop(actor, 'movement_speed', null);
}
}
}
// Mini extra pass: deal with teleporting separately. Otherwise, actors may have been in
// the way of the teleporter but finished moving away during the above loop; this is
// particularly bad when it happens with a block you're pushing.
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
if (actor.just_stepped_on_teleporter) {
this.attempt_teleport(actor, actor === this.player ? this.p1_input : null);
}
}
}
_do_turn_phase(allow_decision) {
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
// Clear any old decisions ASAP. Note that this prop is only used internally within a
// single tic, so it doesn't need to be undoable
actor.decision = null;
if (! actor.cell)
continue;
if (actor.movement_cooldown <= 0 && (allow_decision || actor.slide_mode)) {
if (actor === this.player) {
this.make_player_decision(actor, this.p1_input);
}
else {
this.make_actor_decision(actor);
}
}
this.take_actor_turn(actor);
}
}
take_actor_turn(actor) {
let success = false;
if (! actor.cell)
return;
// FIXME obviously this becomes on_frame
if (actor.type.on_tic) {
actor.type.on_tic(actor, this);
}
// Check this again, since an earlier pass may have caused us to start moving
if (actor.movement_cooldown <= 0) {
// Check for special player actions, which can only happen when not moving
// FIXME if you press a key while moving it should happen as soon as you stop (assuming
// the key is still held down)
// FIXME cc2 seems to rely on key repeat for this; the above is true, but also, if you
// have four bowling balls and hold Q, you'll throw the first, wait a second or so, then
// release the rest rapid-fire. absurd
if (actor === this.player) {
let new_input = this.p1_input & ~this.previous_input;
if (new_input & INPUT_BITS.cycle) {
this.cycle_inventory(this.player);
}
if (new_input & INPUT_BITS.drop) {
this.drop_item(this.player);
}
if (new_input & INPUT_BITS.swap) {
// This is delayed until the end of the tic to avoid screwing up anything
// checking this.player
swap_player1 = true;
}
}
if (actor.decision) {
// Actor is allowed to move, so do so
let old_cell = actor.cell;
success = this.attempt_step(actor, actor.decision);
if (! success && actor.slide_mode === 'ice') {
this._handle_slide_bonk(actor);
success = this.attempt_step(actor, actor.decision);
}
// Track whether the player is blocked, for visual effect
if (actor === this.player && actor.decision && ! success) {
this.sfx.play_once('blocked');
actor.is_blocked = true;
}
}
}
if (actor.movement_cooldown > 0) {
this._set_tile_prop(actor, 'movement_cooldown', Math.max(0, actor.movement_cooldown - 1));
if (actor.movement_cooldown <= 0) {
if (actor.type.ttl) {
// This is an animation that just finished, so destroy it
this.remove_tile(actor);
return;
}
if (! this.compat.tiles_react_instantly) {
this.step_on_cell(actor, actor.cell);
}
// Erase any trace of being in mid-movement, however:
// - This has to happen after stepping on cells, because some effects care about
// the cell we're arriving from
// - Don't do it if stepping on the cell caused us to move again
if (actor.movement_cooldown <= 0) {
this._set_tile_prop(actor, 'previous_cell', null);
this._set_tile_prop(actor, 'movement_speed', null);
}
}
}
return success;
}
// FIXME can probably clean this up a decent bit now
_handle_slide_bonk(actor) {
if (actor.slide_mode === 'ice') {
@ -1119,14 +1327,16 @@ export class Level {
}
this._set_tile_prop(actor, 'previous_cell', actor.cell);
this._set_tile_prop(actor, 'movement_cooldown', speed);
this._set_tile_prop(actor, 'movement_speed', speed);
this._set_tile_prop(actor, 'movement_cooldown', speed * 3);
this._set_tile_prop(actor, 'movement_speed', speed * 3);
this.move_to(actor, goal_cell, speed);
return true;
}
attempt_out_of_turn_step(actor, direction) {
actor.decision = direction;
return this.take_actor_turn(actor);
if (this.attempt_step(actor, direction)) {
// Here's the problem.
// In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not
@ -1149,7 +1359,7 @@ export class Level {
// XXX that's still not perfect; if actor X is tic-misaligned, like if there's a chain
// of 3 or more actors cloning directly onto red buttons for other cloners, then this
// cannot possibly work
actor.cooldown_delay_hack = 1;
//actor.cooldown_delay_hack = 1;
return true;
}
else {

View File

@ -781,7 +781,7 @@ const TILE_TYPES = {
// TODO ms: this is random, and an acting wall to monsters (!)
// TODO lynx/cc2 check this at decision time, which may affect ordering
on_arrive(me, level, other) {
level.set_actor_direction(other, level.get_force_floor_direction());
//level.set_actor_direction(other, level.get_force_floor_direction());
},
},
slime: {
@ -2496,7 +2496,7 @@ const TILE_TYPES = {
is_actor: true,
collision_mask: 0,
blocks_collision: COLLISION.player,
ttl: 6,
ttl: 16,
// If anything else even begins to step on an animation, it's erased
// FIXME possibly erased too fast; cc2 shows it briefly? could i get away with on_arrive here?
on_approach(me, level, other) {
@ -2508,7 +2508,7 @@ const TILE_TYPES = {
is_actor: true,
collision_mask: 0,
blocks_collision: COLLISION.player,
ttl: 6,
ttl: 16,
on_approach(me, level, other) {
level.remove_tile(me);
},
@ -2520,7 +2520,7 @@ const TILE_TYPES = {
collision_mask: 0,
blocks_collision: 0,
// determined experimentally
ttl: 12,
ttl: 36,
},
// Custom VFX (identical function, but different aesthetic)
splash_slime: {
@ -2528,7 +2528,7 @@ const TILE_TYPES = {
is_actor: true,
collision_mask: 0,
blocks_collision: COLLISION.player,
ttl: 6,
ttl: 16,
on_approach(me, level, other) {
level.remove_tile(me);
},