Bring death and spring mining more into line with CC2

- Players and monsters do, in fact, block each other.  The helmet only
  prevents death.

- Death happens during collision check, which is the entire reason items
  don't save you: you're collided with first!  This allows removing
  several special cases.

- Spring mining is prevented almost incidentally, by virtue of collision
  being checked both at decision time and movement time.  It /can/
  happen to actors other than the player, but seemingly not blocks.

- Some monsters, whose movement is essentially forced anyway, skip the
  decision time collision check.  This includes doppelgangers, which is
  why they always spring mine.
This commit is contained in:
Eevee (Evelyn Woods) 2021-05-07 17:46:29 -06:00
parent 24a55d7c88
commit 9883dcf4ef
3 changed files with 101 additions and 79 deletions

View File

@ -148,6 +148,10 @@ export const COMPAT_FLAGS = [
key: 'player_moves_last', key: 'player_moves_last',
label: "Player always moves last", label: "Player always moves last",
rulesets: new Set(['lynx', 'ms']), rulesets: new Set(['lynx', 'ms']),
}, {
key: 'player_dies_during_movement',
label: "Players can't get trampled when standing on items",
rulesets: new Set(['lynx']),
}, { }, {
key: 'emulate_60fps', key: 'emulate_60fps',
label: "Game runs at 60 FPS", label: "Game runs at 60 FPS",
@ -203,16 +207,12 @@ export const COMPAT_FLAGS = [
key: 'monsters_ignore_keys', key: 'monsters_ignore_keys',
label: "Monsters completely ignore keys", label: "Monsters completely ignore keys",
rulesets: new Set(['ms']), rulesets: new Set(['ms']),
}, {
key: 'monsters_blocked_by_items',
label: "Monsters can't step on items to get the player",
rulesets: new Set(['lynx']),
}, },
// Blocks // Blocks
{ {
key: 'no_early_push', key: 'no_early_push',
label: "Player pushes blocks at move time", label: "Pushing blocks happens at move time",
rulesets: new Set(['lynx', 'ms']), rulesets: new Set(['lynx', 'ms']),
}, { }, {
key: 'use_legacy_hooking', key: 'use_legacy_hooking',

View File

@ -55,18 +55,9 @@ export class Tile {
// TODO don't love that the arg order is different here vs tile type, but also don't love that // TODO don't love that the arg order is different here vs tile type, but also don't love that
// the name is the same? // the name is the same?
blocks(other, direction, level) { blocks(other, direction, level) {
// Extremely awkward special case: items don't block monsters if the cell also contains an // Special case: item layer collision is ignored if the cell has an item mod
// item modifier (i.e. "no" sign) or a real player if (this.type.layer === LAYERS.item && this.cell.get_item_mod())
// TODO would love to get this outta here return false;
if ((this.type.is_item || this.type.is_chip) && ! level.compat.monsters_blocked_by_items) {
let item_mod = this.cell.get_item_mod();
if (item_mod && item_mod.type.item_modifier)
return false;
let actor = this.cell.get_actor();
if (actor && actor.type.is_real_player)
return false;
}
if (level.compat.monsters_ignore_keys && this.type.is_key) if (level.compat.monsters_ignore_keys && this.type.is_key)
return false; return false;
@ -74,11 +65,6 @@ export class Tile {
if (this.type.blocks_collision & other.type.collision_mask) if (this.type.blocks_collision & other.type.collision_mask)
return true; return true;
// FIXME bowling ball isn't affected by helmet? also not sure bowling ball is stopped by
// helmet?
if (this.has_item('helmet') || (this.type.is_actor && ! this.type.ttl && other.has_item('helmet')))
return true;
// Blocks being pulled are blocked by their pullers (which are, presumably, the only things // Blocks being pulled are blocked by their pullers (which are, presumably, the only things
// they can be moving towards) // they can be moving towards)
// FIXME something about this broke pulling blocks through teleporters; see #99 Delirium // FIXME something about this broke pulling blocks through teleporters; see #99 Delirium
@ -950,7 +936,7 @@ export class Level extends LevelInterface {
if (actor.type.ttl) { if (actor.type.ttl) {
// Animations, bizarrely, do their cooldown at decision time, so they're removed // Animations, bizarrely, do their cooldown at decision time, so they're removed
// early on the tic that they expire // early on the tic that they expire
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3); this._do_actor_cooldown(actor, this.update_rate);
continue; continue;
} }
@ -1430,10 +1416,19 @@ export class Level extends LevelInterface {
if (actor.type.decide_movement) { if (actor.type.decide_movement) {
direction_preference = actor.type.decide_movement(actor, this); direction_preference = actor.type.decide_movement(actor, this);
} }
// Check which of those directions we *can*, probably, move in
if (! direction_preference) if (! direction_preference)
return; return;
// In CC2, some monsters can only ever have one direction to choose from, so they don't
// bother checking collision at all. (Unfortunately, this causes spring mining.)
// TODO compat flag for this
if (actor.type.skip_decision_time_collision_check) {
actor.decision = direction_preference[0] ?? null;
return;
}
// Check which of those directions we *can*, probably, move in
let push_mode = this.compat.no_early_push ? 'slap' : 'push';
for (let [i, direction] of direction_preference.entries()) { for (let [i, direction] of direction_preference.entries()) {
if (! direction) { if (! direction) {
// This actor is giving up! Alas. // This actor is giving up! Alas.
@ -1447,7 +1442,7 @@ export class Level extends LevelInterface {
direction = actor.cell.redirect_exit(actor, direction); direction = actor.cell.redirect_exit(actor, direction);
if (this.check_movement(actor, actor.cell, direction, 'bump')) { if (this.check_movement(actor, actor.cell, direction, push_mode)) {
// We found a good direction! Stop here // We found a good direction! Stop here
actor.decision = direction; actor.decision = direction;
break; break;
@ -1505,19 +1500,19 @@ export class Level extends LevelInterface {
// - An actor with foil MUST NOT bump a wall on the other side of a thin wall. // - An actor with foil MUST NOT bump a wall on the other side of a thin wall.
// - A ghost with foil MUST bump a wall (even on the other side of a thin wall) and be // - A ghost with foil MUST bump a wall (even on the other side of a thin wall) and be
// deflected by the resulting steel. // deflected by the resulting steel.
// - An actor with foil MUST NOT bump a wall under a "no foil" sign.
// - A bowling ball MUST NOT destroy an actor on the other side of a thin wall, or on top of // - A bowling ball MUST NOT destroy an actor on the other side of a thin wall, or on top of
// a regular wall. // a regular wall.
// - A fireball MUST melt an ice block AND ALSO still be deflected by it, even if the ice // - A fireball MUST melt an ice block AND ALSO still be deflected by it, even if the ice
// block is on top of an item (which blocks the fireball), but NOT one on the other side // block is on top of an item (which blocks the fireball), but NOT one on the other side
// of a thin wall. // of a thin wall.
// - A rover MUST NOT bump walls underneath a canopy (which blocks it). // - A rover MUST NOT bump walls underneath a canopy (which blocks it).
// It seems the order is thus: canopy + thin wall; terrain; actor; item. Which is the usual // It seems the order is thus: canopy + thin wall + item mod (indistinguishable); terrain;
// ordering from the top down, except that terrain is checked before actors. Really, the // actor; item. In other words, some physically logical sense of "outer" to "inner".
// ordering is from "outermost" to "innermost", which makes physical sense.
let still_blocked = false; let still_blocked = false;
for (let layer of [ for (let layer of [
LAYERS.canopy, LAYERS.thin_wall, LAYERS.terrain, LAYERS.swivel, LAYERS.canopy, LAYERS.thin_wall, LAYERS.item_mod, LAYERS.terrain, LAYERS.swivel,
LAYERS.actor, LAYERS.item_mod, LAYERS.item]) LAYERS.actor, LAYERS.item])
{ {
let tile = cell[layer]; let tile = cell[layer];
if (! tile) if (! tile)
@ -1529,6 +1524,16 @@ export class Level extends LevelInterface {
tile.type.on_bumped(tile, this, actor); tile.type.on_bumped(tile, this, actor);
} }
// Death happens here: if a monster or block even thinks about moving into a player, or
// a player thinks about moving into a monster, the player dies. A player standing on a
// wall is only saved by the wall being checked first. This is also why standing on an
// item won't save you: actors are checked before items!
// In Lynx, on the other hand, this is deferred until later (and only happens if the
// move is allowed), so hold off.
if (layer === LAYERS.actor && ! this.compat.player_dies_during_movement) {
this._check_for_player_death(actor, tile);
}
if (! tile.blocks(actor, direction, this)) if (! tile.blocks(actor, direction, this))
continue; continue;
@ -1628,19 +1633,46 @@ export class Level extends LevelInterface {
} }
// In push mode, check one last time for being blocked, in case we e.g. pushed a block // In push mode, check one last time for being blocked, in case we e.g. pushed a block
// off of a recessed wall // off of a recessed wall.
// TODO unclear if this is the right way to emulate spring mining, but without the check // This is the check that prevents spring mining, the phenomenon where (a) actor pushes
// for a player, it happens /too/ often; try allowing for ann actors and running the 163 // a block off of a recessed wall or lilypad, (b) the wall/lilypad becomes blocking as a
// BLOX replay, and right at the end ice blocks spring mine each other. also, the wiki // result, (c) the actor moves into the cell anyway. In most cases this is prevented on
// suggests something about another actor moving away at the same time? // accident, because pushes happen at decision time during the collision check, and then
if (! (this.compat.emulate_spring_mining && actor.type.is_real_player) && // the actual movement happens later with a second collision check.
push_mode === 'push' && cell.some(tile => tile && tile.blocks(actor, direction, this))) // Note that there is one exception: CC2 does seem to have spring mining prevention when
// pushing a row of ice blocks, so we keep the check if we're a block. See BLOX replay;
// without this, ice blocks spring mine around 61.9s.
if ((! this.compat.emulate_spring_mining || actor.type.is_block) &&
push_mode === 'push' &&
cell.some(tile => tile && tile.blocks(actor, direction, this)))
return false; return false;
} }
return ! still_blocked; return ! still_blocked;
} }
_check_for_player_death(actor, tile) {
if (actor.has_item('helmet') || tile.has_item('helmet')) {
// Helmet disables this, do nothing. In most cases, normal collision will kick
// in. Note that this doesn't protect you from bowling balls, which aren't
// blocked by anything.
}
else if (tile.type.is_real_player) {
if (actor.type.is_monster) {
this.kill_actor(tile, actor);
return true;
}
else if (actor.type.is_block && ! actor.is_pulled) {
this.kill_actor(tile, actor, null, null, 'squished');
return true;
}
}
else if (actor.type.is_real_player && tile.type.is_monster) {
this.kill_actor(actor, tile);
return true;
}
}
check_movement(actor, orig_cell, direction, push_mode) { check_movement(actor, orig_cell, direction, push_mode) {
// Lynx: Players can't override backwards on force floors, and it functions like blocking, // Lynx: Players can't override backwards on force floors, and it functions like blocking,
// but does NOT act like a bonk (hence why it's here) // but does NOT act like a bonk (hence why it's here)
@ -1721,9 +1753,17 @@ export class Level extends LevelInterface {
if (! success) if (! success)
return false; return false;
// In Lynx, checking for player trampling happens right about here, more or less.
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
if (this.compat.player_dies_during_movement) {
let tramplee = goal_cell.get_actor();
if (tramplee && this._check_for_player_death(actor, tramplee))
// We stepped on the player (or vice versa); don't move, or we'll erase something
return false;
}
// We're clear! Compute our speed and move us // We're clear! Compute our speed and move us
// FIXME this feels clunky // FIXME this feels clunky
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
let terrain = goal_cell.get_terrain(); let terrain = goal_cell.get_terrain();
if (terrain && terrain.type.speed_factor && ! actor.ignores(terrain.type.name) && !actor.slide_ignores(terrain.type.name)) { if (terrain && terrain.type.speed_factor && ! actor.ignores(terrain.type.name) && !actor.slide_ignores(terrain.type.name)) {
speed /= terrain.type.speed_factor; speed /= terrain.type.speed_factor;
@ -1778,7 +1818,7 @@ export class Level extends LevelInterface {
} }
_do_extra_cooldown(actor) { _do_extra_cooldown(actor) {
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3); this._do_actor_cooldown(actor, this.update_rate);
// Only Lexy has double-cooldown protection // Only Lexy has double-cooldown protection
if (! this.compat.use_lynx_loop) { if (! this.compat.use_lynx_loop) {
this._set_tile_prop(actor, 'last_extra_cooldown_tic', this.tic_counter); this._set_tile_prop(actor, 'last_extra_cooldown_tic', this.tic_counter);
@ -1830,22 +1870,6 @@ export class Level extends LevelInterface {
if (actor.slide_ignores(tile.type.name)) if (actor.slide_ignores(tile.type.name))
continue; continue;
// Possibly kill a player
if (actor.has_item('helmet') || tile.has_item('helmet')) {
// Helmet disables this, do nothing
}
else if (actor.type.is_real_player && tile.type.is_monster) {
this.kill_actor(actor, tile);
}
else if (actor.type.is_monster && tile.type.is_real_player) {
this.kill_actor(tile, actor);
}
else if (actor.type.is_block && tile.type.is_real_player && ! actor.is_pulled) {
// Note that blocks squish players if they move for ANY reason, even if pushed by
// another player! The only exception is being pulled
this.kill_actor(tile, actor, null, null, 'squished');
}
if (tile.type.on_approach) { if (tile.type.on_approach) {
tile.type.on_approach(tile, this, actor); tile.type.on_approach(tile, this, actor);
} }
@ -1866,19 +1890,6 @@ export class Level extends LevelInterface {
this.add_tile(actor, goal_cell); this.add_tile(actor, goal_cell);
} }
// If we're a monster stepping on the player's tail, that also kills her immediately; the
// player and a monster must be strictly more than 4 tics apart
// FIXME this only works for the /current/ player but presumably applies to all of them,
// though i'm having trouble coming up with a test
// TODO the rules in lynx might be slightly different?
if (actor.type.is_monster && goal_cell === this.player.previous_cell &&
// Player has decided to leave their cell, but hasn't actually taken a step yet
this.player.movement_cooldown === this.player.movement_speed &&
! actor.has_item('helmet') && ! this.player.has_item('helmet'))
{
this.kill_actor(this.player, actor);
}
if (this.compat.tiles_react_instantly) { if (this.compat.tiles_react_instantly) {
this.step_on_cell(actor, actor.cell); this.step_on_cell(actor, actor.cell);
} }
@ -2564,7 +2575,7 @@ export class Level extends LevelInterface {
} }
// Otherwise, lose the game // Otherwise, lose the game
this.fail(fail_reason || killer.type.name, null, actor); this.fail(fail_reason || killer.type.name, killer, actor);
return; return;
} }
@ -2603,12 +2614,18 @@ export class Level extends LevelInterface {
if (player) { if (player) {
player.fail_reason = null; player.fail_reason = null;
} }
if (killer) {
killer.is_killer = false;
}
}); });
this.state = 'failure'; this.state = 'failure';
this.fail_reason = reason; this.fail_reason = reason;
if (player) { if (player) {
player.fail_reason = reason; player.fail_reason = reason;
} }
if (killer) {
killer.is_killer = true;
}
} }
win() { win() {

View File

@ -235,7 +235,7 @@ const COMMON_MONSTER = {
is_actor: true, is_actor: true,
is_monster: true, is_monster: true,
collision_mask: COLLISION.monster_generic, collision_mask: COLLISION.monster_generic,
blocks_collision: COLLISION.all_but_real_player, blocks_collision: COLLISION.all,
// Despite the name, this means we only pick up items that are always picked up // Despite the name, this means we only pick up items that are always picked up
item_pickup_priority: PICKUP_PRIORITIES.always, item_pickup_priority: PICKUP_PRIORITIES.always,
movement_speed: 4, movement_speed: 4,
@ -2517,7 +2517,7 @@ const TILE_TYPES = {
return null; return null;
} }
return [direction]; return [direction];
} },
}, },
tank_yellow: { tank_yellow: {
...COMMON_MONSTER, ...COMMON_MONSTER,
@ -2535,17 +2535,20 @@ const TILE_TYPES = {
if (me.pending_decision) { if (me.pending_decision) {
let decision = me.pending_decision; let decision = me.pending_decision;
level._set_tile_prop(me, 'pending_decision', null); level._set_tile_prop(me, 'pending_decision', null);
// Yellow tanks don't keep trying to move if blocked // Yellow tanks don't keep trying to move if blocked, but they DO turn regardless
// XXX consider a compat flag; this is highly unintuitive to me
level.set_actor_direction(me, decision);
return [decision, null]; return [decision, null];
} }
else { else {
return null; return null;
} }
} },
}, },
blob: { blob: {
...COMMON_MONSTER, ...COMMON_MONSTER,
movement_speed: 8, movement_speed: 8,
skip_decision_time_collision_check: true,
decide_movement(me, level) { decide_movement(me, level) {
// move completely at random // move completely at random
let d; let d;
@ -2781,7 +2784,7 @@ const TILE_TYPES = {
is_actor: true, is_actor: true,
is_monster: true, is_monster: true,
collision_mask: COLLISION.block_cc1, collision_mask: COLLISION.block_cc1,
blocks_collision: COLLISION.all_but_real_player, blocks_collision: COLLISION.all,
item_pickup_priority: PICKUP_PRIORITIES.always, item_pickup_priority: PICKUP_PRIORITIES.always,
movement_speed: 4, movement_speed: 4,
// FIXME especially for buttons, destroyed actors should on_depart (behind compat flag) // FIXME especially for buttons, destroyed actors should on_depart (behind compat flag)
@ -2979,7 +2982,7 @@ const TILE_TYPES = {
is_player: true, is_player: true,
is_real_player: true, is_real_player: true,
collision_mask: COLLISION.real_player1, collision_mask: COLLISION.real_player1,
blocks_collision: COLLISION.real_player, blocks_collision: COLLISION.all,
item_pickup_priority: PICKUP_PRIORITIES.real_player, item_pickup_priority: PICKUP_PRIORITIES.real_player,
can_reveal_walls: true, can_reveal_walls: true,
movement_speed: 4, movement_speed: 4,
@ -3003,7 +3006,7 @@ const TILE_TYPES = {
is_player: true, is_player: true,
is_real_player: true, is_real_player: true,
collision_mask: COLLISION.real_player2, collision_mask: COLLISION.real_player2,
blocks_collision: COLLISION.real_player, blocks_collision: COLLISION.all,
item_pickup_priority: PICKUP_PRIORITIES.real_player, item_pickup_priority: PICKUP_PRIORITIES.real_player,
can_reveal_walls: true, can_reveal_walls: true,
movement_speed: 4, movement_speed: 4,
@ -3028,7 +3031,8 @@ const TILE_TYPES = {
is_player: true, is_player: true,
is_monster: true, is_monster: true,
collision_mask: COLLISION.doppel1, collision_mask: COLLISION.doppel1,
blocks_collision: COLLISION.all_but_real_player, blocks_collision: COLLISION.all,
skip_decision_time_collision_check: true,
item_pickup_priority: PICKUP_PRIORITIES.player, item_pickup_priority: PICKUP_PRIORITIES.player,
can_reveal_walls: true, // XXX i think? can_reveal_walls: true, // XXX i think?
movement_speed: 4, movement_speed: 4,
@ -3055,7 +3059,8 @@ const TILE_TYPES = {
is_player: true, is_player: true,
is_monster: true, is_monster: true,
collision_mask: COLLISION.doppel2, collision_mask: COLLISION.doppel2,
blocks_collision: COLLISION.all_but_real_player, blocks_collision: COLLISION.all,
skip_decision_time_collision_check: true,
item_pickup_priority: PICKUP_PRIORITIES.player, item_pickup_priority: PICKUP_PRIORITIES.player,
can_reveal_walls: true, // XXX i think? can_reveal_walls: true, // XXX i think?
movement_speed: 4, movement_speed: 4,