Add compat switches for using the CC2 timing and update order

Other gameplay changes/fixes that crept in:

- Ghosts no longer pick up red keys

- Doppelgangers now read their movement directly from players, so no
  intermediate variables are necessary

- Spring mining is no longer possible

- Push recursion is detected and prevented

- Bowling balls will also blow up anything that runs into them
This commit is contained in:
Eevee (Evelyn Woods) 2020-12-23 04:30:10 -07:00
parent 1aa406fc7b
commit 2381bd38b9
3 changed files with 300 additions and 170 deletions

View File

@ -259,14 +259,17 @@ export class Cell extends Array {
// If we got this far, all that's left is to deal with pushables // If we got this far, all that's left is to deal with pushables
if (pushable_tiles.length > 0) { if (pushable_tiles.length > 0) {
let neighbor_cell = level.get_neighboring_cell(this, direction); // This ends recursive push attempts, which can happen with a row of ice clogged by ice
if (! neighbor_cell) // blocks that are trying to slide
return false; actor._trying_to_push = true;
try {
for (let tile of pushable_tiles) { for (let tile of pushable_tiles) {
if (tile._trying_to_push)
return false;
if (push_mode === 'bump') { if (push_mode === 'bump') {
// FIXME and leaving! // FIXME this doesn't take railroad curves into account, e.g. it thinks a
if (! neighbor_cell.try_entering(tile, direction, level, push_mode)) // rover can't push a block through a curve
if (! level.check_movement(tile, tile.cell, direction, push_mode))
return false; return false;
} }
else if (push_mode === 'push') { else if (push_mode === 'push') {
@ -284,6 +287,18 @@ export class Cell extends Array {
} }
} }
} }
finally {
delete actor._trying_to_push;
}
// In push mode, check one last time for being blocked, in case we e.g. pushed a block
// off of a recessed wall
// TODO deleting this allows spring mining, though i ended up causing it in a more
// aggressive form; try deleting this and running the 163 BLOX replay, it happens with
// ice blocks near the end
if (push_mode === 'push' && this.some(tile => tile.blocks(actor, direction, level)))
return false;
}
return true; return true;
} }
@ -420,9 +435,6 @@ export class Level extends LevelInterface {
} }
} }
// TODO complain if no player // TODO complain if no player
// Used for doppelgangers
this.player1_move = null;
this.player2_move = null;
// Connect buttons and teleporters // Connect buttons and teleporters
let num_cells = this.width * this.height; let num_cells = this.width * this.height;
@ -657,9 +669,10 @@ export class Level extends LevelInterface {
} }
this.begin_tic(p1_input); this.begin_tic(p1_input);
this.finish_tic(p1_input); this.finish_tic();
} }
// FIXME a whole bunch of these comments are gonna be wrong or confusing now
begin_tic(p1_input) { begin_tic(p1_input) {
if (this.undo_enabled) { if (this.undo_enabled) {
// Store some current level state in the undo entry. (These will often not be modified, but // Store some current level state in the undo entry. (These will often not be modified, but
@ -668,7 +681,7 @@ export class Level extends LevelInterface {
'_rng1', '_rng2', '_blob_modifier', 'force_floor_direction', '_rng1', '_rng2', '_blob_modifier', 'force_floor_direction',
'tic_counter', 'time_remaining', 'timer_paused', 'tic_counter', 'time_remaining', 'timer_paused',
'chips_remaining', 'bonus_points', 'hint_shown', 'state', 'chips_remaining', 'bonus_points', 'hint_shown', 'state',
'player1_move', 'player2_move', 'remaining_players', 'player', 'remaining_players', 'player',
]) { ]) {
this.pending_undo.level_props[key] = this[key]; this.pending_undo.level_props[key] = this[key];
} }
@ -677,11 +690,49 @@ export class Level extends LevelInterface {
this.p1_released |= ~p1_input; // Action keys released since we last checked them this.p1_released |= ~p1_input; // Action keys released since we last checked them
this.swap_player1 = false; this.swap_player1 = false;
// This effect only lasts one tic, after which we can move again
this._set_tile_prop(this.player, 'is_blocked', false);
this.sfx.set_player_position(this.player.cell); this.sfx.set_player_position(this.player.cell);
if (this.compat.use_lynx_loop) {
if (this.compat.emulate_60fps) {
this._begin_tic_lynx60();
}
else {
this._begin_tic_lynx();
}
}
else {
this._begin_tic_lexy();
}
}
// Only the Lexy-style loop has a notion of "finishing" a tic, since (unlike the Lynx loop) the
// decision phase happens in the /middle/
finish_tic() {
if (this.compat.use_lynx_loop) {
return;
}
this._do_decision_phase();
// Lexy's separate movement loop
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
this._do_actor_movement(actor, actor.decision);
}
this._do_cleanup_phase();
}
// Lexy-style loop, the one I stumbled upon accidentally. Here, cooldowns happen /first/ as
// their own phase, then decisions are made, then movement happens. This approach has several
// advantages: there's no cooldown on the same tic that movement begins, which lets the renderer
// interpolate between tics more easily; there's no jitter when pushing a block, as is seen in
// CC2; and generally more things happen in parallel, which improves the illusion that all the
// game objects are acting simultaneously.
_begin_tic_lexy() {
// CC2 wiring runs every frame, not every tic, so we need to do it three times, but dealing // 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 // 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 // than intended, so we only want one update between the end of the cooldown pass and the
@ -693,12 +744,8 @@ export class Level extends LevelInterface {
} }
this.update_wiring(); this.update_wiring();
// FIRST PASS: actors tick their cooldowns, finish their movement, and possibly step on // Advance everyone's cooldowns
// cells they were moving into. This has a few advantages: it makes rendering interpolation // Note that we iterate in reverse order, DESPITE keeping dead actors around with null
// 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; // 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 // 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 // they act in forward order! (More subtly, even the decision pass does things like
@ -709,22 +756,159 @@ export class Level extends LevelInterface {
if (! actor.cell) if (! actor.cell)
continue; continue;
if (actor.movement_cooldown <= 0) this._do_actor_cooldown(actor, 3);
}
// 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. (CC2 doesn't need to do
// this because blocks you're pushing are always a frame ahead of you anyway.)
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue; continue;
if (actor.just_stepped_on_teleporter) {
this.attempt_teleport(actor);
}
}
this.update_wiring();
}
// Lynx-style loop: everyone decides, then everyone moves/cools.
_begin_tic_lynx() {
this._do_decision_phase();
this._do_combined_action_phase(3);
this.update_wiring();
this._do_cleanup_phase();
}
// Same as above, but split up to run at 60fps, where only every third frame allows for
// decisions. This is how CC2 works.
_begin_tic_lynx60() {
this._do_decision_phase(true);
this._do_combined_action_phase(1, true);
this.update_wiring();
this._do_decision_phase(true);
this._do_combined_action_phase(1, true);
this.update_wiring();
this._do_decision_phase();
this._do_combined_action_phase(1);
this.update_wiring();
this._do_cleanup_phase();
}
// Decision phase: all actors decide on their movement "simultaneously"
_do_decision_phase(forced_only = false) {
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 (! forced_only && actor.type.on_tic) {
actor.type.on_tic(actor, this);
if (! actor.cell)
continue;
}
if (actor === this.player) {
this.make_player_decision(actor, this.p1_input, forced_only);
}
else {
this.make_actor_decision(actor, forced_only);
}
}
// This only persists for a single decision phase
if (! forced_only) {
this.yellow_tank_decision = null;
}
}
// Lynx's combined action phase: each actor attempts to move, then cools down, in order
_do_combined_action_phase(cooldown, forced_only = false) {
for (let i = this.actors.length - 1; i >= 0; i--) {
let actor = this.actors[i];
if (! actor.cell)
continue;
this._do_actor_movement(actor, actor.decision);
this._do_actor_cooldown(actor, cooldown);
if (actor.just_stepped_on_teleporter) {
this.attempt_teleport(actor);
}
}
}
// Have an actor attempt to move
_do_actor_movement(actor, direction) {
// Check this again, since an earlier pass may have caused us to start moving
if (actor.movement_cooldown > 0)
return;
if (! direction)
return true;
// Actor is allowed to move, so do so
let success = this.attempt_step(actor, direction);
// FIXME not convinced that ice bonking should actually go here. in cc2 it appears to
// happen every frame, fwiw, but i'm not sure if that includes frames with forced moves
// (though i guess that's impossible)
if (! success) {
let terrain = actor.cell.get_terrain();
if (terrain.type.slide_mode === 'ice' && (! actor.ignores(terrain.type.name) ||
// TODO weird cc2 quirk/bug: ghosts bonk on ice even though they don't slide on it
// FIXME and if they have cleats, they get stuck instead (?!)
(actor.type.name === 'ghost' && actor.cell.get_terrain().type.slide_mode === 'ice')))
{
// Bonk on ice: turn the actor around, consult the tile in case it's an ice
// corner, and try again
actor.direction = DIRECTIONS[direction].opposite;
direction = terrain.type.get_slide_direction(terrain, this, actor);
success = this.attempt_step(actor, direction);
}
}
// Track whether the player is blocked, both for visual effect and for doppelgangers
if (actor === this.player && ! success) {
this.sfx.play_once('blocked');
this._set_tile_prop(actor, 'is_blocked', true);
}
return success;
}
_do_actor_cooldown(actor, cooldown = 3) {
if (actor.movement_cooldown <= 0)
return;
if (actor.cooldown_delay_hack) { if (actor.cooldown_delay_hack) {
// See the extensive comment in attempt_out_of_turn_step // See the extensive comment in attempt_out_of_turn_step
actor.cooldown_delay_hack += 1; actor.cooldown_delay_hack += 1;
continue; return;
} }
this._set_tile_prop(actor, 'movement_cooldown', Math.max(0, actor.movement_cooldown - 1)); this._set_tile_prop(actor, 'movement_cooldown', Math.max(0, actor.movement_cooldown - cooldown));
if (actor.movement_cooldown <= 0) { if (actor.movement_cooldown <= 0) {
if (actor.type.ttl) { if (actor.type.ttl) {
// This is an animation that just finished, so destroy it // This is an animation that just finished, so destroy it
this.remove_tile(actor); this.remove_tile(actor);
continue; return;
} }
if (! this.compat.tiles_react_instantly) { if (! this.compat.tiles_react_instantly) {
@ -744,104 +928,7 @@ export class Level extends LevelInterface {
} }
} }
// Mini extra pass: deal with teleporting separately. Otherwise, actors may have been in _do_cleanup_phase() {
// 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);
}
}
// Here's the third.
this.update_wiring();
}
finish_tic() {
// After cooldowns but before the decision phase, remember the player's /current/ direction,
// which may be affected by sliding. This will affect the behavior of doppelgangers earlier
// in the actor order than the player.
if (this.player.movement_cooldown > 0) {
this.remember_player_move(this.player.direction);
}
else {
this.remember_player_move(this.player.decision);
}
// 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, this.p1_input);
}
else {
this.make_actor_decision(actor);
}
}
// This only persists for a single decision phase
this.yellow_tank_decision = null;
// THIRD PASS: everyone actually moves
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;
if (! actor.decision)
continue;
// Actor is allowed to move, so do so
let success = this.attempt_step(actor, actor.decision);
// FIXME not convinced that ice bonking should actually go here. in cc2 it appears to
// happen every frame, fwiw, but i'm not sure if that includes frames with forced moves
// (though i guess that's impossible)
if (! success) {
let terrain = actor.cell.get_terrain();
if (terrain.type.slide_mode === 'ice' && (! actor.ignores(terrain.type.name) ||
// TODO weird cc2 quirk/bug: ghosts bonk on ice even though they don't slide on it
// FIXME and if they have cleats, they get stuck instead (?!)
(actor.type.name === 'ghost' && actor.cell.get_terrain().type.slide_mode === 'ice')))
{
// Bonk on ice: turn the actor around, consult the tile in case it's an ice
// corner, and try again
actor.direction = DIRECTIONS[actor.decision].opposite;
actor.decision = terrain.type.get_slide_direction(terrain, this, 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');
this._set_tile_prop(actor, 'is_blocked', true);
}
}
// Strip out any destroyed actors from the acting order // 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 // 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? // necessary, either; maybe only do it every few tics?
@ -902,7 +989,8 @@ export class Level extends LevelInterface {
// Advance the clock // Advance the clock
// TODO i suspect cc2 does this at the beginning of the tic, but even if you've won? if you // 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 // step on a penalty + exit you win, but you see the clock flicker 1 for a single frame.
// maybe the win check happens at the start of the frame too?
this.tic_counter += 1; this.tic_counter += 1;
if (this.time_remaining !== null && ! this.timer_paused) { if (this.time_remaining !== null && ! this.timer_paused) {
this.time_remaining -= 1; this.time_remaining -= 1;
@ -942,9 +1030,15 @@ export class Level extends LevelInterface {
return [dir1, dir2]; return [dir1, dir2];
} }
make_player_decision(actor, input) { make_player_decision(actor, input, forced_only = false) {
// Only reset the player's is_pushing between movement, so it lasts for the whole push // Only reset the player's is_pushing between movement, so it lasts for the whole push
this._set_tile_prop(actor, 'is_pushing', false); this._set_tile_prop(actor, 'is_pushing', false);
// This effect only lasts one tic, after which we can move again. Note that this one has
// gameplay impact -- doppelgangers use it to know if they should copy your facing direction
// even if you're not moving
if (! forced_only) {
this._set_tile_prop(actor, 'is_blocked', false);
}
// If the game has already been won (or lost), don't bother with a move; it'll misalign the // If the game has already been won (or lost), don't bother with a move; it'll misalign the
// player from their actual position and not accomplish anything gameplay-wise. // player from their actual position and not accomplish anything gameplay-wise.
@ -978,7 +1072,7 @@ export class Level extends LevelInterface {
if (actor.slide_mode) { if (actor.slide_mode) {
forced_decision = terrain.type.get_slide_direction(terrain, this, actor); forced_decision = terrain.type.get_slide_direction(terrain, this, actor);
} }
let may_move = (! actor.slide_mode || (actor.slide_mode === 'force' && actor.last_move_was_force)); let may_move = ! forced_only && (! actor.slide_mode || (actor.slide_mode === 'force' && actor.last_move_was_force));
let [dir1, dir2] = this._extract_player_directions(input); let [dir1, dir2] = this._extract_player_directions(input);
// Check for special player actions, which can only happen at decision time. Dropping can // Check for special player actions, which can only happen at decision time. Dropping can
@ -986,6 +1080,7 @@ export class Level extends LevelInterface {
// can be done freely while sliding. // can be done freely while sliding.
// FIXME cc2 seems to rely on key repeat for this; if you have four bowling balls and hold // FIXME cc2 seems to rely on key repeat for this; 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 // Q, you'll throw the first, wait a second or so, then release the rest rapid-fire. absurd
if (! forced_only) {
let new_input = input & this.p1_released; let new_input = input & this.p1_released;
this.p1_released = 0xff; this.p1_released = 0xff;
if (new_input & INPUT_BITS.cycle) { if (new_input & INPUT_BITS.cycle) {
@ -1002,6 +1097,7 @@ export class Level extends LevelInterface {
this.swap_player1 = true; this.swap_player1 = true;
this.p1_released &= ~INPUT_BITS.swap; this.p1_released &= ~INPUT_BITS.swap;
} }
}
if (actor.slide_mode && ! (may_move && dir1)) { if (actor.slide_mode && ! (may_move && dir1)) {
// This is a forced move and we're not overriding it, so we're done // This is a forced move and we're not overriding it, so we're done
@ -1011,7 +1107,7 @@ export class Level extends LevelInterface {
this._set_tile_prop(actor, 'last_move_was_force', true); this._set_tile_prop(actor, 'last_move_was_force', true);
} }
} }
else if (dir1 === null) { else if (dir1 === null || forced_only) {
// Not attempting to move, so do nothing // Not attempting to move, so do nothing
} }
else { else {
@ -1080,14 +1176,9 @@ export class Level extends LevelInterface {
this._set_tile_prop(actor, 'last_move_was_force', false); this._set_tile_prop(actor, 'last_move_was_force', false);
} }
} }
// Remember our choice for the sake of doppelgangers
// FIXME still a bit unclear on how they handle secondary direction, but i'm not sure that's
// even a real concept in lynx, so maybe this is right??
this.remember_player_move(actor.decision);
} }
make_actor_decision(actor) { make_actor_decision(actor, forced_only = false) {
// Compat flag for blue tanks // Compat flag for blue tanks
if (this.compat.sliding_tanks_ignore_button && if (this.compat.sliding_tanks_ignore_button &&
actor.slide_mode && actor.pending_reverse) actor.slide_mode && actor.pending_reverse)
@ -1114,6 +1205,8 @@ export class Level extends LevelInterface {
actor.decision = terrain.type.get_slide_direction(terrain, this, actor); actor.decision = terrain.type.get_slide_direction(terrain, this, actor);
return; return;
} }
if (forced_only)
return;
if (actor.cell.some(tile => tile.type.traps && tile.type.traps(tile, actor))) { if (actor.cell.some(tile => tile.type.traps && tile.type.traps(tile, actor))) {
// An actor in a cloner or a closed trap can't turn // An actor in a cloner or a closed trap can't turn
// TODO because of this, if a tank is trapped when a blue button is pressed, then // TODO because of this, if a tank is trapped when a blue button is pressed, then
@ -1130,9 +1223,9 @@ export class Level extends LevelInterface {
return; return;
let all_blocked = true; let all_blocked = true;
for (let [i, direction] of direction_preference.entries()) { for (let [i, direction] of direction_preference.entries()) {
if (direction === null) { if (! direction) {
// This actor is giving up! Alas. // This actor is giving up! Alas.
actor.decision = direction; actor.decision = null;
break; break;
} }
if (typeof direction === 'function') { if (typeof direction === 'function') {
@ -1224,14 +1317,20 @@ export class Level extends LevelInterface {
} }
this._set_tile_prop(actor, 'previous_cell', actor.cell); this._set_tile_prop(actor, 'previous_cell', actor.cell);
this._set_tile_prop(actor, 'movement_cooldown', speed); this._set_tile_prop(actor, 'movement_cooldown', speed * 3);
this._set_tile_prop(actor, 'movement_speed', speed); this._set_tile_prop(actor, 'movement_speed', speed * 3);
this.move_to(actor, goal_cell, speed); this.move_to(actor, goal_cell, speed);
return true; return true;
} }
attempt_out_of_turn_step(actor, direction) { attempt_out_of_turn_step(actor, direction) {
if (this.compat.use_lynx_loop) {
let success = this._do_actor_movement(actor, direction);
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3);
return success;
}
if (this.attempt_step(actor, direction)) { if (this.attempt_step(actor, direction)) {
// Here's the problem. // Here's the problem.
// In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not // In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not
@ -1373,7 +1472,8 @@ export class Level extends LevelInterface {
continue; continue;
if (tile.type.is_item && if (tile.type.is_item &&
(actor.type.has_inventory || // FIXME implement item priority i'm begging you
((actor.type.has_inventory && ! (tile.type.name === 'key_red' && ! actor.type.is_player)) ||
cell.some(t => t.type.item_modifier === 'pickup')) && cell.some(t => t.type.item_modifier === 'pickup')) &&
this.attempt_take(actor, tile)) this.attempt_take(actor, tile))
{ {
@ -1471,7 +1571,13 @@ export class Level extends LevelInterface {
// Now physically move the actor and have them take a turn // Now physically move the actor and have them take a turn
this.remove_tile(actor); this.remove_tile(actor);
this.add_tile(actor, dest.cell); this.add_tile(actor, dest.cell);
// FIXME i think the cc2 approach might be to handle this at decision time, hence why
// overriding works at all; it happens to work for me because this happens immediately
// before decision time as a separate pass! for now, simulate by undoing the cooldown
this.attempt_out_of_turn_step(actor, direction); this.attempt_out_of_turn_step(actor, direction);
if (this.compat.use_lynx_loop && actor.movement_cooldown) {
this._set_tile_prop(actor, 'movement_cooldown', actor.movement_cooldown + 1);
}
} }
if (! success && actor.type.has_inventory && teleporter.type.name === 'teleport_yellow') { if (! success && actor.type.has_inventory && teleporter.type.name === 'teleport_yellow') {
// Super duper special yellow teleporter behavior: you pick it the fuck up // Super duper special yellow teleporter behavior: you pick it the fuck up
@ -1483,17 +1589,6 @@ export class Level extends LevelInterface {
} }
} }
remember_player_move(direction) {
if (this.player.type.name === 'player') {
this.player1_move = direction;
this.player2_move = null;
}
else {
this.player1_move = null;
this.player2_move = direction;
}
}
cycle_inventory(actor) { cycle_inventory(actor) {
if (this.stored_level.use_cc1_boots) if (this.stored_level.use_cc1_boots)
return; return;
@ -1966,11 +2061,10 @@ export class Level extends LevelInterface {
spawn_animation(cell, name) { spawn_animation(cell, name) {
let type = TILE_TYPES[name]; let type = TILE_TYPES[name];
let tile = new Tile(type); let tile = new Tile(type);
// Co-opt movement_cooldown/speed for these despite that they aren't moving, since they're // Co-opt movement_cooldown/speed for these despite that they aren't moving, since those
// also used to animate everything else. Decrement the cooldown immediately, to match the // properties are also used to animate everything else anyway. Decrement the cooldown
// normal actor behavior of decrementing one's own cooldown at the end of one's turn // immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in
// FIXME this replicates cc2 behavior, but it also means the animation is actually visible // the decision phase, but this seems to work out just as well
// for one less tic than expected
this._set_tile_prop(tile, 'movement_speed', tile.type.ttl); this._set_tile_prop(tile, 'movement_speed', tile.type.ttl);
this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1); this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1);
cell._add(tile); cell._add(tile);

View File

@ -1006,6 +1006,12 @@ class Player extends PrimaryView {
ret[key] = value; ret[key] = value;
} }
} }
// XXX do not commit this
/*
ret.use_lynx_loop = true;
ret.emulate_60fps = true;
*/
return ret; return ret;
} }
@ -2171,7 +2177,12 @@ class PackTestDialog extends DialogOverlay {
// TODO compat options here?? // TODO compat options here??
let replay = stored_level.replay; let replay = stored_level.replay;
level = new Level(stored_level, {}); level = new Level(stored_level, {
/*
use_lynx_loop: true,
emulate_60fps: true,
*/
});
level.sfx = dummy_sfx; level.sfx = dummy_sfx;
level.force_floor_direction = replay.initial_force_floor_direction; level.force_floor_direction = replay.initial_force_floor_direction;
level._blob_modifier = replay.blob_seed; level._blob_modifier = replay.blob_seed;

View File

@ -2242,7 +2242,22 @@ const TILE_TYPES = {
decide_movement(me, level) { decide_movement(me, level) {
return [me.direction]; return [me.direction];
}, },
on_bumped(me, level, other) {
// Blow up anything that runs into us... unless we're on a cloner
// FIXME there are other cases where this won't be right; this shouldn't happen if the
// cell blocks the actor, but i don't have a callback for that?
if (me.cell.has('cloner'))
return;
if (other.type.is_real_player) {
level.fail(me.type.name);
}
else {
level.transmute_tile(other, 'explosion');
}
level.transmute_tile(me, 'explosion');
},
on_blocked(me, level, direction) { on_blocked(me, level, direction) {
// Blow up anything we run into
let cell = level.get_neighboring_cell(me.cell, direction); let cell = level.get_neighboring_cell(me.cell, direction);
let other; let other;
if (cell) { if (cell) {
@ -2392,8 +2407,13 @@ const TILE_TYPES = {
key_green: true, key_green: true,
}, },
decide_movement(me, level) { decide_movement(me, level) {
if (level.player1_move) { if (level.player.type.name === 'player') {
return [level.player1_move]; if (level.player.movement_cooldown || level.player.is_blocked) {
return [level.player.direction];
}
else {
return [level.player.decision];
}
} }
else { else {
return null; return null;
@ -2427,8 +2447,13 @@ const TILE_TYPES = {
key_yellow: true, key_yellow: true,
}, },
decide_movement(me, level) { decide_movement(me, level) {
if (level.player2_move) { if (level.player.type.name === 'player2') {
return [level.player2_move]; if (level.player.movement_cooldown || level.player.is_blocked) {
return [level.player.direction];
}
else {
return [level.player.decision];
}
} }
else { else {
return null; return null;
@ -2550,7 +2575,7 @@ const TILE_TYPES = {
is_actor: true, is_actor: true,
collision_mask: 0, collision_mask: 0,
blocks_collision: COLLISION.player, blocks_collision: COLLISION.player,
ttl: 6, ttl: 16,
// If anything else even begins to step on an animation, it's erased // 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? // FIXME possibly erased too fast; cc2 shows it briefly? could i get away with on_arrive here?
on_approach(me, level, other) { on_approach(me, level, other) {
@ -2562,7 +2587,7 @@ const TILE_TYPES = {
is_actor: true, is_actor: true,
collision_mask: 0, collision_mask: 0,
blocks_collision: COLLISION.player, blocks_collision: COLLISION.player,
ttl: 6, ttl: 16,
on_approach(me, level, other) { on_approach(me, level, other) {
level.remove_tile(me); level.remove_tile(me);
}, },
@ -2574,7 +2599,7 @@ const TILE_TYPES = {
collision_mask: 0, collision_mask: 0,
blocks_collision: 0, blocks_collision: 0,
// determined experimentally // determined experimentally
ttl: 12, ttl: 36,
}, },
// Custom VFX (identical function, but different aesthetic) // Custom VFX (identical function, but different aesthetic)
splash_slime: { splash_slime: {
@ -2582,7 +2607,7 @@ const TILE_TYPES = {
is_actor: true, is_actor: true,
collision_mask: 0, collision_mask: 0,
blocks_collision: COLLISION.player, blocks_collision: COLLISION.player,
ttl: 6, ttl: 16,
on_approach(me, level, other) { on_approach(me, level, other) {
level.remove_tile(me); level.remove_tile(me);
}, },
@ -2593,13 +2618,13 @@ const TILE_TYPES = {
draw_layer: DRAW_LAYERS.actor, draw_layer: DRAW_LAYERS.actor,
is_actor: true, is_actor: true,
collision_mask: 0, collision_mask: 0,
ttl: 8, ttl: 8 * 3,
}, },
player2_exit: { player2_exit: {
draw_layer: DRAW_LAYERS.actor, draw_layer: DRAW_LAYERS.actor,
is_actor: true, is_actor: true,
collision_mask: 0, collision_mask: 0,
ttl: 8, ttl: 8 * 3,
}, },
// Invalid tiles that appear in some CCL levels because community level // Invalid tiles that appear in some CCL levels because community level