diff --git a/js/defs.js b/js/defs.js index c01556f..e6976cc 100644 --- a/js/defs.js +++ b/js/defs.js @@ -148,6 +148,10 @@ export const COMPAT_FLAGS = [ key: 'player_moves_last', label: "Player always moves last", 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', label: "Game runs at 60 FPS", @@ -203,16 +207,12 @@ export const COMPAT_FLAGS = [ key: 'monsters_ignore_keys', label: "Monsters completely ignore keys", 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 { key: 'no_early_push', - label: "Player pushes blocks at move time", + label: "Pushing blocks happens at move time", rulesets: new Set(['lynx', 'ms']), }, { key: 'use_legacy_hooking', diff --git a/js/game.js b/js/game.js index e7050d8..d723a25 100644 --- a/js/game.js +++ b/js/game.js @@ -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 // the name is the same? blocks(other, direction, level) { - // Extremely awkward special case: items don't block monsters if the cell also contains an - // item modifier (i.e. "no" sign) or a real player - // TODO would love to get this outta here - 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; - } + // Special case: item layer collision is ignored if the cell has an item mod + if (this.type.layer === LAYERS.item && this.cell.get_item_mod()) + return false; if (level.compat.monsters_ignore_keys && this.type.is_key) return false; @@ -74,11 +65,6 @@ export class Tile { if (this.type.blocks_collision & other.type.collision_mask) 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 // they can be moving towards) // 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) { // Animations, bizarrely, do their cooldown at decision time, so they're removed // 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; } @@ -1430,10 +1416,19 @@ export class Level extends LevelInterface { if (actor.type.decide_movement) { direction_preference = actor.type.decide_movement(actor, this); } - - // Check which of those directions we *can*, probably, move in if (! direction_preference) 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()) { if (! direction) { // This actor is giving up! Alas. @@ -1447,7 +1442,7 @@ export class Level extends LevelInterface { 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 actor.decision = direction; 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. // - A ghost with foil MUST bump a wall (even on the other side of a thin wall) and be // 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 regular wall. // - 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 // of a thin wall. // - 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 - // ordering from the top down, except that terrain is checked before actors. Really, the - // ordering is from "outermost" to "innermost", which makes physical sense. + // It seems the order is thus: canopy + thin wall + item mod (indistinguishable); terrain; + // actor; item. In other words, some physically logical sense of "outer" to "inner". let still_blocked = false; for (let layer of [ - LAYERS.canopy, LAYERS.thin_wall, LAYERS.terrain, LAYERS.swivel, - LAYERS.actor, LAYERS.item_mod, LAYERS.item]) + LAYERS.canopy, LAYERS.thin_wall, LAYERS.item_mod, LAYERS.terrain, LAYERS.swivel, + LAYERS.actor, LAYERS.item]) { let tile = cell[layer]; if (! tile) @@ -1529,6 +1524,16 @@ export class Level extends LevelInterface { 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)) 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 - // off of a recessed wall - // TODO unclear if this is the right way to emulate spring mining, but without the check - // for a player, it happens /too/ often; try allowing for ann actors and running the 163 - // BLOX replay, and right at the end ice blocks spring mine each other. also, the wiki - // suggests something about another actor moving away at the same time? - if (! (this.compat.emulate_spring_mining && actor.type.is_real_player) && - push_mode === 'push' && cell.some(tile => tile && tile.blocks(actor, direction, this))) + // off of a recessed wall. + // This is the check that prevents spring mining, the phenomenon where (a) actor pushes + // a block off of a recessed wall or lilypad, (b) the wall/lilypad becomes blocking as a + // result, (c) the actor moves into the cell anyway. In most cases this is prevented on + // accident, because pushes happen at decision time during the collision check, and then + // the actual movement happens later with a second collision check. + // 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 ! 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) { // 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) @@ -1721,9 +1753,17 @@ export class Level extends LevelInterface { if (! success) 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 // FIXME this feels clunky - let goal_cell = this.get_neighboring_cell(actor.cell, direction); let terrain = goal_cell.get_terrain(); if (terrain && terrain.type.speed_factor && ! actor.ignores(terrain.type.name) && !actor.slide_ignores(terrain.type.name)) { speed /= terrain.type.speed_factor; @@ -1778,7 +1818,7 @@ export class Level extends LevelInterface { } _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 if (! this.compat.use_lynx_loop) { 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)) 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) { tile.type.on_approach(tile, this, actor); } @@ -1866,19 +1890,6 @@ export class Level extends LevelInterface { 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) { this.step_on_cell(actor, actor.cell); } @@ -2564,7 +2575,7 @@ export class Level extends LevelInterface { } // Otherwise, lose the game - this.fail(fail_reason || killer.type.name, null, actor); + this.fail(fail_reason || killer.type.name, killer, actor); return; } @@ -2603,12 +2614,18 @@ export class Level extends LevelInterface { if (player) { player.fail_reason = null; } + if (killer) { + killer.is_killer = false; + } }); this.state = 'failure'; this.fail_reason = reason; if (player) { player.fail_reason = reason; } + if (killer) { + killer.is_killer = true; + } } win() { diff --git a/js/tiletypes.js b/js/tiletypes.js index c89c144..b551b73 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -235,7 +235,7 @@ const COMMON_MONSTER = { is_actor: true, is_monster: true, 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 item_pickup_priority: PICKUP_PRIORITIES.always, movement_speed: 4, @@ -2517,7 +2517,7 @@ const TILE_TYPES = { return null; } return [direction]; - } + }, }, tank_yellow: { ...COMMON_MONSTER, @@ -2535,17 +2535,20 @@ const TILE_TYPES = { if (me.pending_decision) { let decision = me.pending_decision; 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]; } else { return null; } - } + }, }, blob: { ...COMMON_MONSTER, movement_speed: 8, + skip_decision_time_collision_check: true, decide_movement(me, level) { // move completely at random let d; @@ -2781,7 +2784,7 @@ const TILE_TYPES = { is_actor: true, is_monster: true, collision_mask: COLLISION.block_cc1, - blocks_collision: COLLISION.all_but_real_player, + blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.always, movement_speed: 4, // FIXME especially for buttons, destroyed actors should on_depart (behind compat flag) @@ -2979,7 +2982,7 @@ const TILE_TYPES = { is_player: true, is_real_player: true, collision_mask: COLLISION.real_player1, - blocks_collision: COLLISION.real_player, + blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.real_player, can_reveal_walls: true, movement_speed: 4, @@ -3003,7 +3006,7 @@ const TILE_TYPES = { is_player: true, is_real_player: true, collision_mask: COLLISION.real_player2, - blocks_collision: COLLISION.real_player, + blocks_collision: COLLISION.all, item_pickup_priority: PICKUP_PRIORITIES.real_player, can_reveal_walls: true, movement_speed: 4, @@ -3028,7 +3031,8 @@ const TILE_TYPES = { is_player: true, is_monster: true, 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, can_reveal_walls: true, // XXX i think? movement_speed: 4, @@ -3055,7 +3059,8 @@ const TILE_TYPES = { is_player: true, is_monster: true, 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, can_reveal_walls: true, // XXX i think? movement_speed: 4,