2995 lines
119 KiB
JavaScript
2995 lines
119 KiB
JavaScript
import * as algorithms from './algorithms.js';
|
|
import { DIRECTIONS, DIRECTION_ORDER, LAYERS, INPUT_BITS, PICKUP_PRIORITIES, TICS_PER_SECOND } from './defs.js';
|
|
import { LevelInterface } from './format-base.js';
|
|
import TILE_TYPES from './tiletypes.js';
|
|
|
|
export class Tile {
|
|
constructor(type, direction = 'south') {
|
|
this.type = type;
|
|
if (type.is_actor) {
|
|
this.direction = direction;
|
|
}
|
|
this.cell = null;
|
|
|
|
if (type.is_actor) {
|
|
this.slide_mode = null;
|
|
this.movement_cooldown = 0;
|
|
}
|
|
|
|
// Pre-seed actors who are expected to have inventories, with one
|
|
// TODO do i need this at all?
|
|
if (type.item_pickup_priority <= PICKUP_PRIORITIES.normal) {
|
|
this.keyring = {};
|
|
this.toolbelt = [];
|
|
}
|
|
}
|
|
|
|
static from_template(tile_template) {
|
|
let type = tile_template.type;
|
|
if (! type) console.error(tile_template);
|
|
let tile = new this(type, tile_template.direction);
|
|
// Copy any extra properties in verbatim
|
|
return Object.assign(tile, tile_template);
|
|
}
|
|
|
|
movement_progress(update_progress, update_rate) {
|
|
return (this.movement_speed - this.movement_cooldown + update_rate * (update_progress - 1)) / this.movement_speed;
|
|
}
|
|
|
|
// Gives the effective position of an actor in motion, given smooth scrolling
|
|
visual_position(update_progress = 0, update_rate = 0) {
|
|
if (! this.previous_cell || this.movement_speed === null) {
|
|
return [this.cell.x, this.cell.y];
|
|
}
|
|
|
|
let cell = this.destination_cell ?? this.cell;
|
|
// For a movement speed of N, the cooldown is set to N - R at the end of the frame/tic an
|
|
// actor starts moving, and we interpolate it from N to that
|
|
let p = this.movement_progress(update_progress, update_rate);
|
|
return [
|
|
(1 - p) * this.previous_cell.x + p * cell.x,
|
|
(1 - p) * this.previous_cell.y + p * cell.y,
|
|
];
|
|
}
|
|
|
|
// 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) {
|
|
// 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;
|
|
|
|
if (this.type.blocks_collision & other.type.collision_mask)
|
|
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
|
|
if (this.type.is_actor && other.type.is_block && other.is_pulled)
|
|
return true;
|
|
|
|
// FIXME get this out of here
|
|
if (this.type.thin_walls &&
|
|
this.type.thin_walls.has(DIRECTIONS[direction].opposite) &&
|
|
other.type.name !== 'ghost')
|
|
return true;
|
|
|
|
if (this.type.blocks && this.type.blocks(this, level, other, direction))
|
|
return true;
|
|
|
|
if (other.type.blocked_by && other.type.blocked_by(other, level, this))
|
|
return true;
|
|
|
|
return false;
|
|
}
|
|
|
|
ignores(name) {
|
|
if (this.type.ignores && this.type.ignores.has(name))
|
|
return true;
|
|
|
|
if (this.toolbelt) {
|
|
for (let item of this.toolbelt) {
|
|
if (! item)
|
|
continue;
|
|
let item_type = TILE_TYPES[item];
|
|
if (item_type.item_ignores && item_type.item_ignores.has(name))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
slide_ignores(name) {
|
|
if (this.type.slide_ignores && this.type.slide_ignores.has(name))
|
|
return true;
|
|
|
|
if (this.toolbelt) {
|
|
for (let item of this.toolbelt) {
|
|
if (! item)
|
|
continue;
|
|
let item_type = TILE_TYPES[item];
|
|
if (item_type.item_slide_ignores && item_type.item_slide_ignores.has(name))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
can_push(tile, direction, level) {
|
|
// This tile already has a push queued, sorry
|
|
if (tile.pending_push)
|
|
return false;
|
|
|
|
if (! (this.type.pushes && this.type.pushes[tile.type.name] &&
|
|
(! tile.type.allows_push || tile.type.allows_push(tile, direction))))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// CC2 strikes again: blocks cannot push sliding blocks, except that frame blocks can push
|
|
// sliding dirt blocks!
|
|
if (this.type.is_block && tile.slide_mode && ! (
|
|
this.type.name === 'frame_block' && tile.type.name === 'dirt_block'))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
// Obey railroad curvature
|
|
direction = tile.cell.redirect_exit(tile, direction);
|
|
// Need to explicitly check this here, otherwise you could /attempt/ to push a block,
|
|
// which would fail, but it would still change the block's direction
|
|
return level.can_actor_leave_cell(tile, tile.cell, direction);
|
|
}
|
|
|
|
can_pull(tile, direction) {
|
|
// FIXME starting to think fx should not count as actors
|
|
if (tile.type.ttl)
|
|
return false;
|
|
|
|
// FIXME i don't actually know the precise rules here. dirt blocks and ghosts can pull
|
|
// other blocks even though they can't usually push them. given the existence of monster
|
|
// hooking, i suspect /anything/ can be hooked but on monsters it has a weird effect?
|
|
// figure this out?
|
|
return (! tile.type.allows_push || tile.type.allows_push(tile, direction));
|
|
}
|
|
|
|
// Inventory stuff
|
|
has_item(name) {
|
|
if (TILE_TYPES[name].is_key) {
|
|
return this.keyring && (this.keyring[name] ?? 0) > 0;
|
|
}
|
|
else {
|
|
return this.toolbelt && this.toolbelt.some(item => item === name);
|
|
}
|
|
}
|
|
}
|
|
Tile.prototype.emitting_edges = 0;
|
|
Tile.prototype.powered_edges = 0;
|
|
Tile.prototype.wire_directions = 0;
|
|
Tile.prototype.wire_tunnel_directions = 0;
|
|
|
|
export class Cell extends Array {
|
|
constructor(x, y) {
|
|
super(LAYERS.MAX);
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
|
|
_add(tile) {
|
|
let index = tile.type.layer;
|
|
if (this[index]) {
|
|
if (index !== LAYERS.vfx) {
|
|
console.error("ATTEMPTING TO ADD", tile, "TO CELL", this, "WHICH ERASES EXISTING TILE", this[index]);
|
|
}
|
|
this[index].cell = null;
|
|
}
|
|
this[index] = tile;
|
|
tile.cell = this;
|
|
}
|
|
|
|
// DO NOT use me to remove a tile permanently, only to move it!
|
|
// Should only be called from Level, which handles some bookkeeping!
|
|
_remove(tile) {
|
|
let index = tile.type.layer;
|
|
if (this[index] !== tile)
|
|
throw new Error("Asked to remove tile that doesn't seem to exist");
|
|
|
|
this[index] = null;
|
|
tile.cell = null;
|
|
}
|
|
|
|
get_wired_tile() {
|
|
let ret = null;
|
|
for (let tile of this) {
|
|
if (tile && (tile.wire_directions || tile.wire_tunnel_directions) && ! tile.movement_cooldown) {
|
|
ret = tile;
|
|
// Don't break; we want the topmost tile!
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
get_terrain() {
|
|
return this[LAYERS.terrain] ?? null;
|
|
}
|
|
|
|
get_actor() {
|
|
return this[LAYERS.actor] ?? null;
|
|
}
|
|
|
|
get_item() {
|
|
return this[LAYERS.item] ?? null;
|
|
}
|
|
|
|
get_item_mod() {
|
|
return this[LAYERS.item_mod] ?? null;
|
|
}
|
|
|
|
has(name) {
|
|
let current = this[TILE_TYPES[name].layer];
|
|
return current && current.type.name === name;
|
|
}
|
|
|
|
// Special railroad ability: change the direction we attempt to leave
|
|
redirect_exit(actor, direction) {
|
|
let terrain = this.get_terrain();
|
|
if (terrain && terrain.type.redirect_exit) {
|
|
return terrain.type.redirect_exit(terrain, actor, direction);
|
|
}
|
|
return direction;
|
|
}
|
|
}
|
|
|
|
// The undo stack is implemented with a ring buffer, and this is its size. One entry per tic.
|
|
// Based on Chrome measurements made against the pathological level CCLP4 #40 (Periodic Lasers) and
|
|
// sitting completely idle, undo consumes about 2 MB every five seconds, so this shouldn't go beyond
|
|
// 12 MB for any remotely reasonable level.
|
|
const UNDO_BUFFER_SIZE = TICS_PER_SECOND * 30;
|
|
// The CC1 inventory has a fixed boot order
|
|
const CC1_INVENTORY_ORDER = ['cleats', 'suction_boots', 'fire_boots', 'flippers'];
|
|
export class Level extends LevelInterface {
|
|
constructor(stored_level, compat = {}) {
|
|
super();
|
|
this.stored_level = stored_level;
|
|
this.restart(compat);
|
|
}
|
|
|
|
get update_rate() {
|
|
if (this.compat.use_lynx_loop && this.compat.emulate_60fps) {
|
|
return 1;
|
|
}
|
|
else {
|
|
return 3;
|
|
}
|
|
}
|
|
|
|
// Level setup ------------------------------------------------------------------------------------
|
|
|
|
restart(compat) {
|
|
this.compat = compat;
|
|
|
|
// playing: normal play
|
|
// success: has been won
|
|
// failure: died
|
|
// note that pausing is NOT handled here, but by whatever's driving our
|
|
// event loop!
|
|
this.state = 'playing';
|
|
|
|
this.width = this.stored_level.size_x;
|
|
this.height = this.stored_level.size_y;
|
|
this.size_x = this.stored_level.size_x;
|
|
this.size_y = this.stored_level.size_y;
|
|
|
|
this.linear_cells = [];
|
|
this.player = null;
|
|
this.p1_input = 0;
|
|
this.p1_released = 0xff;
|
|
this.actors = [];
|
|
this.chips_remaining = this.stored_level.chips_required ?? 0;
|
|
this.bonus_points = 0;
|
|
this.aid = 0;
|
|
|
|
// Time
|
|
this.done_on_begin = false;
|
|
if (this.stored_level.time_limit === 0) {
|
|
this.time_remaining = null;
|
|
}
|
|
else {
|
|
this.time_remaining = this.stored_level.time_limit * 20;
|
|
}
|
|
this.timer_paused = false;
|
|
// Note that this clock counts *up*, even on untimed levels, and is unaffected by CC2's
|
|
// clock alteration shenanigans
|
|
this.tic_counter = 0;
|
|
// 0 to 2, counting which frame within a tic we're on in CC2
|
|
this.frame_offset = 0;
|
|
// 0 to 7, indicating the first tic that teeth can move on.
|
|
// 0 is equivalent to even step; 4 is equivalent to odd step.
|
|
// 5 is the default in CC2. Lynx can use any of the 8. MSCC uses
|
|
// either 0 or 4, and defaults to 0, but which you get depends on the
|
|
// global clock which doesn't get reset between levels (!).
|
|
this.step_parity = 5;
|
|
|
|
// TODO in lynx/steam, this carries over between levels; in tile world, you can set it manually
|
|
this.force_floor_direction = 'north';
|
|
// PRNG is initialized to zero
|
|
this._rng1 = 0;
|
|
this._rng2 = 0;
|
|
this._tw_rng = Math.floor(Math.random() * 0x80000000);
|
|
if (this.stored_level.blob_behavior === 0) {
|
|
this._blob_modifier = 0x55;
|
|
}
|
|
else {
|
|
// The other two modes are initialized to a random seed
|
|
this._blob_modifier = Math.floor(Math.random() * 256);
|
|
}
|
|
|
|
this.undo_buffer = new Array(UNDO_BUFFER_SIZE);
|
|
for (let i = 0; i < UNDO_BUFFER_SIZE; i++) {
|
|
this.undo_buffer[i] = null;
|
|
}
|
|
this.undo_buffer_index = 0;
|
|
this.pending_undo = this.create_undo_entry();
|
|
// If undo_enabled is false, we won't create any undo entries.
|
|
// Undo is only disabled during bulk testing, where a) there's no
|
|
// possibility of needing to undo and b) the overhead is noticable.
|
|
this.undo_enabled = true;
|
|
|
|
let n = 0;
|
|
let connectables = [];
|
|
this.remaining_players = 0;
|
|
this.ankh_tile = null;
|
|
// If there's exactly one yellow teleporter when the level loads, it cannot be picked up
|
|
let yellow_teleporter_count = 0;
|
|
this.allow_taking_yellow_teleporters = false;
|
|
// Sokoban buttons function as a group
|
|
this.sokoban_buttons_unpressed = {};
|
|
for (let y = 0; y < this.height; y++) {
|
|
let row = [];
|
|
for (let x = 0; x < this.width; x++) {
|
|
let cell = new Cell(x, y);
|
|
row.push(cell);
|
|
this.linear_cells.push(cell);
|
|
|
|
let stored_cell = this.stored_level.linear_cells[n];
|
|
n++;
|
|
for (let template_tile of stored_cell) {
|
|
if (! template_tile)
|
|
continue;
|
|
|
|
let tile = Tile.from_template(template_tile);
|
|
if (tile.type.is_hint) {
|
|
// Copy over the tile-specific hint, if any
|
|
tile.hint_text = template_tile.hint_text ?? null;
|
|
}
|
|
|
|
if (tile.type.is_real_player) {
|
|
this.remaining_players += 1;
|
|
if (this.player === null) {
|
|
this.player = tile;
|
|
}
|
|
}
|
|
if (tile.type.is_required_chip && this.stored_level.chips_required === null) {
|
|
this.chips_remaining++;
|
|
}
|
|
if (tile.type.is_actor) {
|
|
this.actors.push(tile);
|
|
}
|
|
cell._add(tile);
|
|
|
|
if (tile.type.connects_to) {
|
|
connectables.push(tile);
|
|
}
|
|
|
|
if (tile.type.name === 'teleport_yellow' && ! this.allow_taking_yellow_teleporters) {
|
|
yellow_teleporter_count += 1;
|
|
if (yellow_teleporter_count > 1) {
|
|
this.allow_taking_yellow_teleporters = true;
|
|
}
|
|
}
|
|
else if (tile.type.name === 'sokoban_button') {
|
|
this.sokoban_buttons_unpressed[tile.color] =
|
|
(this.sokoban_buttons_unpressed[tile.color] ?? 0) + 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (this.compat.player_moves_last) {
|
|
let i = this.actors.indexOf(this.player);
|
|
if (i > 0) {
|
|
[this.actors[0], this.actors[i]] = [this.actors[i], this.actors[0]];
|
|
}
|
|
}
|
|
// TODO complain if no player
|
|
// Used for doppelgängers
|
|
this.player1_move = null;
|
|
this.player2_move = null;
|
|
|
|
// Connect buttons and teleporters
|
|
let num_cells = this.width * this.height;
|
|
for (let connectable of connectables) {
|
|
this.connect_button(connectable);
|
|
}
|
|
|
|
this.recalculate_circuitry_next_wire_phase = false;
|
|
this.undid_past_recalculate_circuitry = false;
|
|
this.recalculate_circuitry(true);
|
|
|
|
// Finally, let all tiles do custom init behavior... but backwards, to match actor order
|
|
for (let i = this.linear_cells.length - 1; i >= 0; i--) {
|
|
let cell = this.linear_cells[i];
|
|
for (let tile of cell) {
|
|
if (! tile)
|
|
continue;
|
|
if (tile.type.on_ready) {
|
|
tile.type.on_ready(tile, this);
|
|
}
|
|
}
|
|
}
|
|
// Erase undo, in case any on_ready added to it (we don't want to undo initialization!)
|
|
this.pending_undo = this.create_undo_entry();
|
|
}
|
|
|
|
connect_button(connectable) {
|
|
let cell = connectable.cell;
|
|
let x = cell.x;
|
|
let y = cell.y;
|
|
// FIXME this is a single string for red/brown buttons (to match iter_tiles_in_RO) but a
|
|
// set for orange buttons (because flame jet states are separate tiles), which sucks ass
|
|
let goals = connectable.type.connects_to;
|
|
|
|
// Check for custom wiring, for MSCC .DAT levels
|
|
// TODO would be neat if this applied to orange buttons too
|
|
// TODO RAINBOW TELEPORTER, ARBITRARY TILE TARGET HAHA
|
|
if (this.stored_level.has_custom_connections) {
|
|
let n = this.stored_level.coords_to_scalar(x, y);
|
|
let target_cell_n = null;
|
|
if (connectable.type.name === 'button_brown' || connectable.type.name === 'button_red') {
|
|
target_cell_n = this.stored_level.custom_connections[n] ?? null;
|
|
}
|
|
if (target_cell_n && target_cell_n < this.width * this.height) {
|
|
let [tx, ty] = this.stored_level.scalar_to_coords(target_cell_n);
|
|
for (let tile of this.cell(tx, ty)) {
|
|
if (tile && goals === tile.type.name) {
|
|
connectable.connection = tile;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Orange buttons do a really weird diamond search
|
|
if (connectable.type.connect_order === 'diamond') {
|
|
for (let cell of algorithms.iter_cells_in_diamond(
|
|
this, connectable.cell.x, connectable.cell.y))
|
|
{
|
|
let target = null;
|
|
for (let tile of cell) {
|
|
if (tile && goals.has(tile.type.name)) {
|
|
target = tile;
|
|
break;
|
|
}
|
|
}
|
|
if (target !== null) {
|
|
connectable.connection = target;
|
|
break;
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Otherwise, look in reading order
|
|
for (let tile of this.iter_tiles_in_reading_order(cell, goals)) {
|
|
// TODO ideally this should be a weak connection somehow, since dynamite can destroy
|
|
// empty cloners and probably traps too
|
|
connectable.connection = tile;
|
|
// Just grab the first
|
|
break;
|
|
}
|
|
}
|
|
|
|
recalculate_circuitry(first_time = false, undoing = false) {
|
|
// Build circuits out of connected wires
|
|
// TODO document this idea
|
|
|
|
if (!first_time) {
|
|
for (let circuit of this.circuits) {
|
|
for (let tile of circuit.tiles) {
|
|
tile[0].circuits = [null, null, null, null];
|
|
}
|
|
}
|
|
}
|
|
|
|
this.circuits = [];
|
|
this.power_sources = [];
|
|
let wired_outputs = new Set;
|
|
this.wired_outputs = [];
|
|
let add_to_edge_map = (map, item, edges) => {
|
|
map.set(item, (map.get(item) ?? 0) | edges);
|
|
};
|
|
for (let cell of this.linear_cells) {
|
|
// We're interested in static circuitry, which means terrain
|
|
// OR circuit blocks on top
|
|
let terrain = cell.get_terrain();
|
|
if (! terrain) // ?!
|
|
continue;
|
|
|
|
if (terrain.type.is_power_source) {
|
|
this.power_sources.push(terrain);
|
|
}
|
|
|
|
let actor = cell.get_actor();
|
|
let wire_directions = terrain.wire_directions;
|
|
// FIXME this doesn't allow a blank circuit block to erase wires,
|
|
// but it can't anyway because Tile.wire_directions = 0; need some
|
|
// other way to identify a tile as wired, or at least an actor
|
|
if (actor && actor.wire_directions &&
|
|
(actor.movement_cooldown === 0 || this.compat.tiles_react_instantly))
|
|
{
|
|
wire_directions = actor.wire_directions;
|
|
}
|
|
|
|
if (! wire_directions && ! terrain.wire_tunnel_directions) {
|
|
// No wires, not interesting... unless it's a logic gate, which defines its own
|
|
// wires! We only care about outgoing ones here, on the off chance that they point
|
|
// directly into a non-wired tile, in which case a wire scan won't find them
|
|
if (terrain.type.name === 'logic_gate') {
|
|
let dir = terrain.direction;
|
|
let cxns = terrain.type._gate_types[terrain.gate_type];
|
|
if (! cxns) {
|
|
// Voodoo tile
|
|
continue;
|
|
}
|
|
for (let i = 0; i < 4; i++) {
|
|
let cxn = cxns[i];
|
|
if (cxn && cxn.match(/^out/)) {
|
|
wire_directions |= DIRECTIONS[dir].bit;
|
|
}
|
|
dir = DIRECTIONS[dir].right;
|
|
}
|
|
}
|
|
else {
|
|
continue;
|
|
}
|
|
}
|
|
|
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
|
if (! ((wire_directions | terrain.wire_tunnel_directions) & dirinfo.bit))
|
|
continue;
|
|
|
|
if (terrain.circuits && terrain.circuits[dirinfo.index])
|
|
continue;
|
|
|
|
let circuit = {
|
|
is_powered: first_time ? false : null,
|
|
tiles: new Map,
|
|
inputs: new Map,
|
|
};
|
|
this.circuits.push(circuit);
|
|
// At last, a wired cell edge we have not yet handled. Floodfill from here
|
|
algorithms.trace_floor_circuit(
|
|
this, terrain.cell, direction,
|
|
// Wire handling
|
|
(tile, edges) => {
|
|
if (! tile.circuits) {
|
|
tile.circuits = [null, null, null, null];
|
|
}
|
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
|
if (edges & dirinfo.bit) {
|
|
tile.circuits[dirinfo.index] = circuit;
|
|
}
|
|
}
|
|
add_to_edge_map(circuit.tiles, tile, edges);
|
|
if (tile.type.on_power) {
|
|
// Red teleporters contain wires and /also/ have an on_power
|
|
// FIXME this isn't quite right since there's seemingly a 1-frame delay
|
|
wired_outputs.add(tile);
|
|
}
|
|
|
|
if (tile.type.is_power_source) {
|
|
// TODO could just do this in a pass afterwards
|
|
add_to_edge_map(circuit.inputs, tile, edges);
|
|
}
|
|
},
|
|
// Dead end handling (potentially logic gates, etc.)
|
|
(cell, edge) => {
|
|
for (let tile of cell) {
|
|
if (! tile) {
|
|
continue;
|
|
}
|
|
else if (tile.type.name === 'logic_gate') {
|
|
// Logic gates are the one non-wired tile that get attached to circuits,
|
|
// mostly so blue teleporters can follow them
|
|
if (! tile.circuits) {
|
|
tile.circuits = [null, null, null, null];
|
|
}
|
|
tile.circuits[DIRECTIONS[edge].index] = circuit;
|
|
|
|
let wire = tile.type._gate_types[tile.gate_type][
|
|
(DIRECTIONS[edge].index - DIRECTIONS[tile.direction].index + 4) % 4];
|
|
if (! wire)
|
|
return;
|
|
add_to_edge_map(circuit.tiles, tile, DIRECTIONS[edge].bit);
|
|
if (wire.match(/^out/)) {
|
|
add_to_edge_map(circuit.inputs, tile, DIRECTIONS[edge].bit);
|
|
}
|
|
}
|
|
else if (tile.type.on_power) {
|
|
// FIXME this isn't quite right since there's seemingly a 1-frame delay
|
|
add_to_edge_map(circuit.tiles, tile, DIRECTIONS[edge].bit);
|
|
wired_outputs.add(tile);
|
|
}
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|
|
this.wired_outputs = Array.from(wired_outputs);
|
|
this.wired_outputs.sort((a, b) => this.coords_to_scalar(a.cell.x, a.cell.y) - this.coords_to_scalar(b.cell.x, b.cell.y));
|
|
|
|
if (!first_time) {
|
|
//update wireables
|
|
for (var i = 0; i < this.width; ++i)
|
|
{
|
|
for (var j = 0; j < this.height; ++j)
|
|
{
|
|
let terrain = this.cell(i, j).get_terrain();
|
|
if (terrain.is_wired !== undefined)
|
|
{
|
|
terrain.type.on_begin(terrain, this);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!undoing) {
|
|
this._push_pending_undo(() => this.undid_past_recalculate_circuitry = true);
|
|
}
|
|
}
|
|
}
|
|
|
|
can_accept_input() {
|
|
// We can accept input anytime the player can move, i.e. when they're not already moving and
|
|
// not in an un-overrideable slide
|
|
return this.player.movement_cooldown === 0 &&
|
|
(this.player.slide_mode === null || (
|
|
this.player.slide_mode === 'force' && this.player.last_move_was_force));
|
|
}
|
|
|
|
// Randomness -------------------------------------------------------------------------------------
|
|
|
|
// Lynx PRNG, used unchanged in CC2
|
|
prng() {
|
|
let n = (this._rng1 >> 2) - this._rng1;
|
|
if (!(this._rng1 & 0x02)) {
|
|
n -= 1;
|
|
}
|
|
this._rng1 = ((this._rng1 >> 1) | (this._rng2 & 0x80)) & 0xff;
|
|
this._rng2 = ((this._rng2 << 1) | (n & 0x01)) & 0xff;
|
|
return this._rng1 ^ this._rng2;
|
|
}
|
|
|
|
// Tile World's PRNG, used for blobs in Tile World Lynx
|
|
_advance_tw_prng() {
|
|
this._tw_rng = ((Math.imul(this._tw_rng, 1103515245) & 0x7fffffff) + 12345) & 0x7fffffff;
|
|
}
|
|
tw_prng_random4() {
|
|
let x = this._tw_rng;
|
|
this._advance_tw_prng();
|
|
return this._tw_rng >> 29;
|
|
}
|
|
|
|
// Weird thing done by CC2 to make blobs... more... random
|
|
get_blob_modifier() {
|
|
let mod = this._blob_modifier;
|
|
|
|
if (this.stored_level.blob_behavior === 1) {
|
|
// "4 patterns" just increments by 1 every time
|
|
mod = (mod + 1) % 4;
|
|
}
|
|
else {
|
|
// Other modes do this curious operation
|
|
mod *= 2;
|
|
if (mod < 255) {
|
|
mod ^= 0x1d;
|
|
}
|
|
mod &= 0xff;
|
|
}
|
|
|
|
this._blob_modifier = mod;
|
|
return mod;
|
|
}
|
|
|
|
// Main loop --------------------------------------------------------------------------------------
|
|
|
|
// Move the game state forwards by one tic.
|
|
// Input is a bit mask of INPUT_BITS.
|
|
advance_tic(p1_input) {
|
|
if (this.state !== 'playing') {
|
|
console.warn(`Attempting to advance game when state is ${this.state}`);
|
|
return;
|
|
}
|
|
|
|
// If someone is mixing tics and frames, run in frames until the end of the tic
|
|
if (this.frame_offset > 0) {
|
|
for (let i = this.frame_offset; i < 3; i++) {
|
|
this.advance_frame(p1_input);
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._do_init_phase();
|
|
this._set_p1_input(p1_input);
|
|
|
|
if (this.compat.use_lynx_loop) {
|
|
if (this.compat.emulate_60fps) {
|
|
this._advance_tic_lynx60();
|
|
}
|
|
else {
|
|
this._advance_tic_lynx();
|
|
}
|
|
}
|
|
else {
|
|
this._advance_tic_lexy();
|
|
}
|
|
}
|
|
|
|
// Default loop: run at 20 tics per second, split things into some more loops
|
|
_advance_tic_lexy() {
|
|
// Under CC2 rules, there are two wire updates at the very beginning of the game before the
|
|
// player can actually move. That means the first tic has five wire phases total.
|
|
if (this.tic_counter === 0) {
|
|
this._do_wire_phase();
|
|
this._do_wire_phase();
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
// Advance everyone's cooldowns
|
|
// 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.type.ttl) {
|
|
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.)
|
|
// This is also where we handle tiles with persistent standing behavior.
|
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
|
let actor = this.actors[i];
|
|
if (! actor.cell)
|
|
continue;
|
|
if (actor.type.ttl)
|
|
continue;
|
|
|
|
this._do_actor_idle(actor);
|
|
}
|
|
|
|
this._swap_players();
|
|
this._do_post_actor_phase();
|
|
|
|
// Wire updates every frame, which means thrice per tic
|
|
this._do_wire_phase();
|
|
this._do_wire_phase();
|
|
this._do_wire_phase();
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Lynx loop: everyone decides, then everyone moves/cools in a single pass
|
|
_advance_tic_lynx() {
|
|
this._do_decision_phase();
|
|
this._do_combined_action_phase(3);
|
|
this._do_post_actor_phase();
|
|
this._do_wire_phase();
|
|
this._do_wire_phase();
|
|
this._do_wire_phase();
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// CC2 loop: similar to the Lynx loop, but run three times per tic, and non-forced decisions can
|
|
// only be made every third frame
|
|
_advance_tic_lynx60() {
|
|
this._do_decision_phase(true);
|
|
this._do_combined_action_phase(1, true);
|
|
this._do_post_actor_phase();
|
|
this._do_wire_phase();
|
|
|
|
this.frame_offset = 1;
|
|
this._do_decision_phase(true);
|
|
this._do_combined_action_phase(1, true);
|
|
this._do_post_actor_phase();
|
|
this._do_wire_phase();
|
|
|
|
this.frame_offset = 2;
|
|
this._do_decision_phase();
|
|
this._do_combined_action_phase(1);
|
|
this._do_post_actor_phase();
|
|
this._do_wire_phase();
|
|
|
|
this.frame_offset = 0;
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Attempt to advance by one FRAME at a time. Primarily useful for running 60 FPS mode at,
|
|
// well, 60 FPS.
|
|
advance_frame(p1_input) {
|
|
if (this.compat.use_lynx_loop && this.compat.emulate_60fps) {
|
|
// Lynx 60, i.e. CC2
|
|
if (this.frame_offset === 0) {
|
|
this._do_init_phase(p1_input);
|
|
}
|
|
this._set_p1_input(p1_input);
|
|
let is_decision_frame = this.frame_offset === 2;
|
|
|
|
this._do_decision_phase(! is_decision_frame);
|
|
this._do_combined_action_phase(1, ! is_decision_frame);
|
|
this._do_post_actor_phase();
|
|
this._do_wire_phase();
|
|
|
|
if (this.frame_offset === 2) {
|
|
this._do_cleanup_phase();
|
|
}
|
|
}
|
|
else {
|
|
// This is either Lexy mode or Lynx mode, and either way we run at 20 tps
|
|
if (this.frame_offset === 0) {
|
|
this.advance_tic(p1_input);
|
|
}
|
|
}
|
|
|
|
this.frame_offset = (this.frame_offset + 1) % 3;
|
|
}
|
|
|
|
_set_p1_input(p1_input) {
|
|
this.p1_input = p1_input;
|
|
this.p1_released |= ~p1_input; // Action keys released since we last checked them
|
|
this.swap_player1 = false;
|
|
}
|
|
|
|
_do_init_phase() {
|
|
// At the beginning of the very first tic, some tiles want to do initialization that's not
|
|
// appropriate to do before the game begins. (For example, bombs blow up anything that
|
|
// starts on them in CC2, but we don't want to do that before the game has run at all. We
|
|
// DEFINITELY don't want to blow the PLAYER up before the game starts!)
|
|
if (! this.done_on_begin) {
|
|
// Run backwards, to match actor order
|
|
for (let i = this.linear_cells.length - 1; i >= 0; i--) {
|
|
let cell = this.linear_cells[i];
|
|
for (let tile of cell) {
|
|
if (tile && tile.type.on_begin) {
|
|
tile.type.on_begin(tile, this);
|
|
}
|
|
}
|
|
}
|
|
// It's not possible to rewind to before this happened, so clear undo and permanently
|
|
// set a flag
|
|
this.pending_undo = this.create_undo_entry();
|
|
this.done_on_begin = true;
|
|
}
|
|
|
|
if (this.undo_enabled) {
|
|
// Store some current level state in the undo entry. (These will often not be modified, but
|
|
// they only take a few bytes each so that's fine.)
|
|
for (let key of [
|
|
'_rng1', '_rng2', '_blob_modifier', '_tw_rng', 'force_floor_direction',
|
|
'tic_counter', 'frame_offset', 'time_remaining', 'timer_paused',
|
|
'chips_remaining', 'bonus_points', 'state',
|
|
'player1_move', 'player2_move', 'remaining_players', 'player',
|
|
]) {
|
|
this.pending_undo.level_props[key] = this[key];
|
|
}
|
|
}
|
|
|
|
this.sfx.set_player_position(this.player.cell);
|
|
this.pending_green_toggle = false;
|
|
}
|
|
|
|
// Decision phase: all actors decide on their movement "simultaneously"
|
|
_do_decision_phase(forced_only = false) {
|
|
// Before decisions happen, remember the player's /current/ direction, which may be affected
|
|
// by sliding. This will be used by doppelgängers earlier in actor order than the player.
|
|
if (! forced_only) {
|
|
// Check whether the player is /attempting/ to move: either they did, or they're blocked
|
|
if (this.player.movement_cooldown > 0 || this.player.is_blocked) {
|
|
this.remember_player_move(this.player.direction);
|
|
}
|
|
else {
|
|
this.remember_player_move(null);
|
|
}
|
|
}
|
|
|
|
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;
|
|
// This is a renderer prop and only exists between two loops
|
|
if (actor.destination_cell) {
|
|
this._set_tile_prop(actor, 'destination_cell', null);
|
|
}
|
|
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
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.update_rate);
|
|
continue;
|
|
}
|
|
|
|
if (actor.movement_cooldown > 0)
|
|
continue;
|
|
|
|
// Erase old traces of movement now
|
|
if (actor.movement_speed) {
|
|
this._set_tile_prop(actor, 'previous_cell', null);
|
|
this._set_tile_prop(actor, 'movement_speed', null);
|
|
if (actor.is_pulled) {
|
|
this._set_tile_prop(actor, 'is_pulled', false);
|
|
}
|
|
if (actor.not_swimming) {
|
|
this._set_tile_prop(actor, 'not_swimming', false);
|
|
}
|
|
}
|
|
|
|
if (actor === this.player) {
|
|
this.make_player_decision(actor, this.p1_input, forced_only);
|
|
}
|
|
else {
|
|
this.make_actor_decision(actor, forced_only);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
if (actor.type.ttl)
|
|
continue;
|
|
|
|
this._do_actor_cooldown(actor, cooldown);
|
|
this._do_actor_idle(actor);
|
|
}
|
|
|
|
this._swap_players();
|
|
}
|
|
|
|
// 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;
|
|
|
|
// Clear this here since we use it to prevent pushing a block that's already been pushed.
|
|
// Also avoids a perverse CC2 ordering issue: a player with a hook sets this on a block at
|
|
// decision time; the block makes its decision (based on this); but then the player acts and
|
|
// sets this /again/ so it carries over and the block tries to move an extra time next turn.
|
|
if (actor.pending_push) {
|
|
this._set_tile_prop(actor, 'pending_push', null);
|
|
}
|
|
// Turntable slide wears off after a single /attempted/ move
|
|
if (actor.slide_mode === 'turntable') {
|
|
this.make_slide(actor, null);
|
|
}
|
|
|
|
// Actor is allowed to move, so do so
|
|
let success = this.attempt_step(actor, direction);
|
|
|
|
// CC2 handles bonking for all kinds of sliding here -- bonking on ice causes an immediate
|
|
// turnaround, and bonking on a force floor tries again (including rolling a new RFF)
|
|
// TODO this assumes the slide comes from the terrain, which is always the case atm
|
|
if (! success) {
|
|
let terrain = actor.cell.get_terrain();
|
|
if (terrain && (
|
|
// Actors bonk on ice even if they're not already sliding (whether because they
|
|
// started on ice or dropped boots on ice)
|
|
// 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 (?!)
|
|
(terrain.type.slide_mode === 'ice' && (
|
|
! actor.ignores(terrain.type.name) || actor.type.name === 'ghost')) ||
|
|
// But they only bonk on a force floor if it affects them
|
|
(terrain.type.slide_mode === 'force' &&
|
|
actor.slide_mode && ! actor.ignores(terrain.type.name))))
|
|
{
|
|
// Turn the actor around so ice corners bonk correctly
|
|
if (terrain.type.slide_mode === 'ice') {
|
|
this.set_actor_direction(actor, DIRECTIONS[direction].opposite);
|
|
}
|
|
// Pretend they stepped on the tile again
|
|
// Note that ghosts bonk even on ice corners, which they can otherwise pass through,
|
|
// argh!
|
|
if (terrain.type.on_arrive && actor.type.name !== 'ghost') {
|
|
terrain.type.on_arrive(terrain, this, actor);
|
|
}
|
|
// If we got a new direction, try moving again
|
|
if (direction !== actor.direction && ! this.compat.bonking_isnt_instant) {
|
|
success = this.attempt_step(actor, actor.direction);
|
|
}
|
|
}
|
|
else if (actor.slide_mode === 'teleport') {
|
|
// Failed teleport slides only last for a single attempt. (Successful teleports
|
|
// continue the slide until landing on a new tile, as normal; otherwise you couldn't
|
|
// push a block coming out of a teleporter.)
|
|
this.make_slide(actor, null);
|
|
}
|
|
}
|
|
|
|
// Track whether the player is blocked, both for visual effect and for doppelgangers
|
|
if (actor === this.player && ! success) {
|
|
if (actor.last_blocked_direction !== actor.direction) {
|
|
// This is only used for checking when to play the mmf sound, doesn't need undoing;
|
|
// it's cleared when we make a successful move or a null decision
|
|
actor.last_blocked_direction = actor.direction;
|
|
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.last_extra_cooldown_tic === this.tic_counter)
|
|
return;
|
|
|
|
this._set_tile_prop(actor, 'movement_cooldown', Math.max(0, actor.movement_cooldown - cooldown));
|
|
|
|
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);
|
|
}
|
|
// Note that we don't erase the movement bookkeeping until next decision phase, because
|
|
// the renderer interpolates back in time and needs to know to draw us finishing the
|
|
// move; this should be fine since everything checks for "in motion" by looking at
|
|
// movement_cooldown, which is already zero. (Also saves some undo budget, since
|
|
// movement_speed is never null for an actor in constant motion.)
|
|
}
|
|
}
|
|
|
|
_do_actor_idle(actor) {
|
|
if (actor.movement_cooldown <= 0) {
|
|
let terrain = actor.cell.get_terrain();
|
|
if (terrain.type.on_stand && ! actor.ignores(terrain.type.name)) {
|
|
terrain.type.on_stand(terrain, this, actor);
|
|
}
|
|
}
|
|
// Lynx gives everything in an open trap an extra cooldown, which makes things walk into
|
|
// open traps at double speed and does weird things to the ejection timing
|
|
if (this.compat.traps_like_lynx) {
|
|
let terrain = actor.cell.get_terrain();
|
|
if (terrain && terrain.type.name === 'trap' && terrain.presses > 0) {
|
|
this._do_extra_cooldown(actor);
|
|
}
|
|
}
|
|
if (actor.just_stepped_on_teleporter) {
|
|
this.attempt_teleport(actor);
|
|
}
|
|
}
|
|
|
|
_swap_players() {
|
|
if (this.remaining_players <= 0) {
|
|
this.win();
|
|
}
|
|
|
|
// Possibly switch players
|
|
// FIXME cc2 has very poor interactions between this feature and cloners; come up with some
|
|
// better rules as a default
|
|
if (this.swap_player1) {
|
|
this.swap_player1 = false;
|
|
// Reset the set of keys released since last tic (but not the swap key, or holding it
|
|
// will swap us endlessly)
|
|
// FIXME this doesn't even quite work, it just swaps less aggressively? wtf
|
|
this.p1_released = 0xff & ~INPUT_BITS.swap;
|
|
// Clear remembered moves
|
|
this.player1_move = null;
|
|
this.player2_move = null;
|
|
|
|
// Iterate backwards over the actor list looking for a viable next player to control
|
|
let i0 = this.actors.indexOf(this.player);
|
|
if (i0 < 0) {
|
|
i0 = 0;
|
|
}
|
|
let i = i0;
|
|
while (true) {
|
|
i -= 1;
|
|
if (i < 0) {
|
|
i += this.actors.length;
|
|
}
|
|
if (i === i0)
|
|
break;
|
|
|
|
let actor = this.actors[i];
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
if (actor.type.is_real_player) {
|
|
this.player = actor;
|
|
if (this.compat.player_moves_last && i !== 0) {
|
|
[this.actors[0], this.actors[i]] = [this.actors[i], this.actors[0]];
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_do_post_actor_phase() {
|
|
if (this.pending_green_toggle) {
|
|
// Swap green floors and walls
|
|
// TODO could probably make this more compact for undo purposes
|
|
for (let cell of this.linear_cells) {
|
|
let terrain = cell.get_terrain();
|
|
if (terrain.type.name === 'green_floor') {
|
|
this.transmute_tile(terrain, 'green_wall');
|
|
}
|
|
else if (terrain.type.name === 'green_wall') {
|
|
this.transmute_tile(terrain, 'green_floor');
|
|
}
|
|
|
|
let item = cell.get_item();
|
|
if (item && item.type.name === 'green_chip') {
|
|
this.transmute_tile(item, 'green_bomb');
|
|
}
|
|
else if (item && item.type.name === 'green_bomb') {
|
|
this.transmute_tile(item, 'green_chip');
|
|
}
|
|
}
|
|
this.pending_green_toggle = false;
|
|
}
|
|
}
|
|
|
|
_do_cleanup_phase() {
|
|
// Lynx compat: Any blue tank that still has the reversal flag set here, but is in motion,
|
|
// should ignore it. Unfortunately this has to be done as its own pass (as it is in Lynx!)
|
|
// because of acting order issues
|
|
if (this.compat.tanks_ignore_button_while_moving) {
|
|
for (let actor of this.actors) {
|
|
if (actor.cell && actor.pending_reverse && actor.movement_cooldown > 0) {
|
|
this._set_tile_prop(actor, 'pending_reverse', false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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];
|
|
if (actor.cell || (
|
|
// Don't strip out actors under Lynx, where slots were reused -- unless they're VFX,
|
|
// which aren't in the original game and thus are exempt
|
|
this.compat.reuse_actor_slots && actor.type.layer !== LAYERS.vfx))
|
|
{
|
|
if (p !== i) {
|
|
this.actors[p] = actor;
|
|
}
|
|
p += 1;
|
|
}
|
|
else {
|
|
let local_p = p;
|
|
this._push_pending_undo(() => this.actors.splice(local_p, 0, actor));
|
|
}
|
|
}
|
|
this.actors.length = p;
|
|
|
|
// 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.
|
|
// maybe the win check happens at the start of the frame too?
|
|
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();
|
|
}
|
|
|
|
// TODO this only has one caller
|
|
_extract_player_directions(input) {
|
|
// Extract directions from an input mask
|
|
let dir1 = null, dir2 = null;
|
|
if (((input & INPUT_BITS['up']) && (input & INPUT_BITS['down'])) ||
|
|
((input & INPUT_BITS['left']) && (input & INPUT_BITS['right'])))
|
|
{
|
|
// If two opposing directions are held at the same time, all input is ignored, so we
|
|
// can't end up with more than 2 directions
|
|
}
|
|
else {
|
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
|
if (input & INPUT_BITS[dirinfo.action]) {
|
|
if (dir1 === null) {
|
|
dir1 = direction;
|
|
}
|
|
else {
|
|
dir2 = direction;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return [dir1, dir2];
|
|
}
|
|
|
|
make_player_decision(actor, input, forced_only = false) {
|
|
// 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 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);
|
|
}
|
|
|
|
// TODO player in a cloner can't move (but player in a trap can still turn)
|
|
|
|
let try_direction = (direction, push_mode) => {
|
|
direction = actor.cell.redirect_exit(actor, direction);
|
|
// FIXME if the player steps into a monster cell here, they die instantly! but only
|
|
// if the cell doesn't block them??
|
|
return this.check_movement(actor, actor.cell, direction, push_mode);
|
|
};
|
|
|
|
// The player is unusual in several ways.
|
|
// - Only the current player can override a force floor (and only if their last move was an
|
|
// involuntary force floor slide, perhaps before some number of ice slides).
|
|
// - The player "block slaps", a phenomenon where they physically attempt to make both of
|
|
// their desired movements, having an impact on the world if appropriate, before deciding
|
|
// which of them to use.
|
|
// - These two properties combine in a subtle way. If we're on a force floor sliding right
|
|
// under a row of blue walls, then if we hold up, we will bump every wall along the way.
|
|
// If we hold up /and right/, we will only bump every other wall. That is, if we're on a
|
|
// force floor and attempt to override but /fail/, it's not held against us -- but if we
|
|
// succeed, even if overriding in the same direction we're already moving, that does count
|
|
// as an override.
|
|
let terrain = actor.cell.get_terrain();
|
|
let may_move = ! forced_only && (
|
|
! actor.slide_mode ||
|
|
(actor.slide_mode === 'force' && actor.last_move_was_force) ||
|
|
((actor.slide_mode === 'teleport' || actor.slide_mode === 'teleport-forever') &&
|
|
actor.cell.get_terrain().type.teleport_allow_override));
|
|
let [dir1, dir2] = this._extract_player_directions(input);
|
|
|
|
// Check for special player actions, which can only happen at decision time. Dropping can
|
|
// only be done when the player is allowed to make a move (i.e. override), but the other two
|
|
// can be done freely while sliding.
|
|
// 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
|
|
if (! forced_only) {
|
|
let new_input = input & this.p1_released;
|
|
if (new_input & INPUT_BITS.cycle) {
|
|
this.cycle_inventory(this.player);
|
|
this.p1_released &= ~INPUT_BITS.cycle;
|
|
}
|
|
if ((new_input & INPUT_BITS.drop) && may_move) {
|
|
if (this.drop_item(this.player)) {
|
|
this.sfx.play_once('drop');
|
|
}
|
|
this.p1_released &= ~INPUT_BITS.drop;
|
|
}
|
|
if ((new_input & INPUT_BITS.swap) && this.remaining_players > 1) {
|
|
// This is delayed until the end of the tic to avoid screwing up anything
|
|
// checking this.player
|
|
this.swap_player1 = true;
|
|
this.p1_released &= ~INPUT_BITS.swap;
|
|
}
|
|
}
|
|
|
|
if (actor.slide_mode && ! (may_move && dir1)) {
|
|
// This is a forced move and we're not overriding it, so we're done
|
|
actor.decision = actor.direction;
|
|
|
|
if (actor.slide_mode === 'force') {
|
|
this._set_tile_prop(actor, 'last_move_was_force', true);
|
|
}
|
|
}
|
|
else if (dir1 === null || forced_only) {
|
|
// Not attempting to move, so do nothing
|
|
}
|
|
else {
|
|
// At this point, we have exactly 1 or 2 directions, and deciding between them requires
|
|
// checking which ones are blocked. Note that we do this even if only one direction is
|
|
// requested, meaning that we get to push blocks before anything else has moved!
|
|
let push_mode = this.compat.no_early_push ? 'slap' : 'push';
|
|
let open;
|
|
if (dir2 === null) {
|
|
// Only one direction is held, but for consistency, "check" it anyway
|
|
open = try_direction(dir1, push_mode);
|
|
actor.decision = dir1;
|
|
}
|
|
else {
|
|
// We have two directions. If one of them is our current facing, we prefer that
|
|
// one, UNLESS it's blocked AND the other isn't.
|
|
// Note that if this is an override, then the forced direction is still used to
|
|
// interpret our input!
|
|
if (dir1 === actor.direction || dir2 === actor.direction) {
|
|
let other_direction = dir1 === actor.direction ? dir2 : dir1;
|
|
let curr_open = try_direction(actor.direction, push_mode);
|
|
let other_open = try_direction(other_direction, push_mode);
|
|
if (! curr_open && other_open) {
|
|
actor.decision = other_direction;
|
|
open = true;
|
|
}
|
|
else {
|
|
actor.decision = actor.direction;
|
|
open = curr_open;
|
|
}
|
|
}
|
|
else {
|
|
// Neither direction is the way we're moving, so try both and prefer horizontal
|
|
// FIXME i'm told cc2 prefers orthogonal actually, but need to check on that
|
|
// FIXME lynx only checks horizontal, what about cc2? it must check both
|
|
// because of the behavior where pushing into a corner always pushes horizontal
|
|
let open1 = try_direction(dir1, push_mode);
|
|
let open2 = try_direction(dir2, push_mode);
|
|
if (open1 && ! open2) {
|
|
actor.decision = dir1;
|
|
open = true;
|
|
}
|
|
else if (! open1 && open2) {
|
|
actor.decision = dir2;
|
|
open = true;
|
|
}
|
|
else if (dir1 === 'east' || dir1 === 'west') {
|
|
actor.decision = dir1;
|
|
open = open1;
|
|
}
|
|
else {
|
|
actor.decision = dir2;
|
|
open = open2;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we're overriding a force floor but the direction we're moving in is blocked, this
|
|
// counts as a forced move (but only under the CC2 behavior of instant bonking)
|
|
if (actor.slide_mode === 'force' && ! open && ! this.compat.bonking_isnt_instant) {
|
|
this._set_tile_prop(actor, 'last_move_was_force', true);
|
|
}
|
|
else {
|
|
// Otherwise this is 100% a conscious move so we lose our override power next tic
|
|
// TODO how does this interact with teleports
|
|
this._set_tile_prop(actor, 'last_move_was_force', false);
|
|
}
|
|
}
|
|
|
|
if (actor.decision === null && ! forced_only) {
|
|
actor.last_blocked_direction = null;
|
|
}
|
|
|
|
// Remember our decision so doppelgängers can copy it
|
|
this.remember_player_move(actor.decision);
|
|
}
|
|
|
|
make_actor_decision(actor, forced_only = false) {
|
|
if (actor.pending_push) {
|
|
// Blocks that were pushed while sliding will move in the push direction as soon as
|
|
// they can make a decision, even if they're still sliding or are off-tic. Also used
|
|
// for hooking. (Note that if the block is on a force floor and is blocked in the push
|
|
// direction, under CC2 rules it'll then try the force floor; see attempt_step.)
|
|
// This isn't cleared until the block actually attempts a move; see _do_actor_movement.
|
|
actor.decision = actor.pending_push;
|
|
return;
|
|
}
|
|
|
|
let direction_preference;
|
|
let terrain = actor.cell.get_terrain();
|
|
if (actor.slide_mode ||
|
|
// 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' && terrain.type.slide_mode === 'ice'))
|
|
{
|
|
// Actors can't make voluntary moves while sliding; they just, ah, slide.
|
|
actor.decision = actor.direction;
|
|
return;
|
|
}
|
|
if (forced_only)
|
|
return;
|
|
if (terrain.type.traps && terrain.type.traps(terrain, this, actor)) {
|
|
// 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
|
|
// when released, it will make one move out of the trap and /then/ turn around and
|
|
// go back into the trap. this is consistent with CC2 but not ms/lynx
|
|
return;
|
|
}
|
|
if (actor.type.decide_movement) {
|
|
direction_preference = actor.type.decide_movement(actor, this);
|
|
}
|
|
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.
|
|
actor.decision = null;
|
|
break;
|
|
}
|
|
if (typeof direction === 'function') {
|
|
// Lazy direction calculation (used for walkers)
|
|
direction = direction();
|
|
}
|
|
|
|
direction = actor.cell.redirect_exit(actor, direction);
|
|
|
|
if (this.check_movement(actor, actor.cell, direction, push_mode)) {
|
|
// We found a good direction! Stop here
|
|
actor.decision = direction;
|
|
break;
|
|
}
|
|
|
|
// If every other preference be blocked, actors unconditionally try the last one
|
|
// (and might even be able to move that way by the time their turn comes!)
|
|
if (i === direction_preference.length - 1) {
|
|
actor.decision = direction;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Actor movement ---------------------------------------------------------------------------------
|
|
|
|
can_actor_leave_cell(actor, cell, direction, push_mode) {
|
|
// The only tiles that can trap us are thin walls and terrain, so for perf (this is very hot
|
|
// code), only bother checking those)
|
|
let terrain = cell[LAYERS.terrain];
|
|
let thin_walls = cell[LAYERS.thin_wall];
|
|
let blocker;
|
|
|
|
if (thin_walls && thin_walls.type.blocks_leaving && thin_walls.type.blocks_leaving(thin_walls, actor, direction)) {
|
|
blocker = thin_walls;
|
|
}
|
|
else if (terrain.type.traps && terrain.type.traps(terrain, this, actor)) {
|
|
blocker = terrain;
|
|
}
|
|
else if (terrain.type.blocks_leaving && terrain.type.blocks_leaving(terrain, actor, direction)) {
|
|
blocker = terrain;
|
|
}
|
|
|
|
if (blocker) {
|
|
if (push_mode === 'push') {
|
|
if (actor.type.on_blocked) {
|
|
actor.type.on_blocked(actor, this, direction, blocker);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Check if this actor can move this direction into this cell. Returns true on success. May
|
|
// have side effects, depending on the value of push_mode:
|
|
// - null: Default. Do not impact game state. Treat pushable objects as blocking.
|
|
// - 'bump': Fire bump triggers. Don't move pushable objects, but do check whether they /could/
|
|
// be pushed, recursively if necessary.
|
|
// - 'slap': Like 'bump', but also sets the 'decision' of pushable objects.
|
|
// - 'push': Fire bump triggers. Attempt to move pushable objects out of the way immediately.
|
|
can_actor_enter_cell(actor, cell, direction, push_mode = null) {
|
|
let pushable_tiles = [];
|
|
// Subtleties ahoy! This is **EXTREMELY** sensitive to ordering. Consider:
|
|
// - 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 + 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.item_mod, LAYERS.terrain, LAYERS.swivel,
|
|
LAYERS.actor, LAYERS.item])
|
|
{
|
|
let tile = cell[layer];
|
|
if (! tile)
|
|
continue;
|
|
|
|
let original_name = tile.type.name;
|
|
// TODO check ignores here?
|
|
if (tile.type.on_bumped) {
|
|
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;
|
|
|
|
if (tile.type.on_after_bumped) {
|
|
tile.type.on_after_bumped(tile, this, actor);
|
|
}
|
|
|
|
if (push_mode === null)
|
|
return false;
|
|
|
|
if (actor.can_push(tile, direction, this) || (
|
|
this.compat.tanks_teeth_push_ice_blocks && tile.type.name === 'ice_block' &&
|
|
(actor.type.name === 'teeth' || actor.type.name === 'teeth_timid' || actor.type.name === 'tank_blue')
|
|
)) {
|
|
// Collect pushables for later, so we don't inadvertently push through a wall
|
|
pushable_tiles.push(tile);
|
|
}
|
|
else {
|
|
// It's in our way and we can't push it, so we're done here
|
|
if (push_mode === 'push') {
|
|
if (actor.type.on_blocked) {
|
|
actor.type.on_blocked(actor, this, direction, tile);
|
|
}
|
|
// Lynx (or at least TW?) allows pushing blocks off of particular wall types
|
|
if (this.compat.allow_pushing_blocks_off_faux_walls &&
|
|
['fake_wall', 'wall_invisible', 'wall_appearing'].includes(original_name))
|
|
{
|
|
still_blocked = true;
|
|
continue;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// If we got this far, all that's left is to deal with pushables
|
|
if (pushable_tiles.length > 0) {
|
|
// This ends recursive push attempts, which can happen with a row of ice clogged by ice
|
|
// blocks that are trying to slide
|
|
actor._trying_to_push = true;
|
|
try {
|
|
for (let tile of pushable_tiles) {
|
|
if (tile._trying_to_push)
|
|
return false;
|
|
if (push_mode === 'bump' || push_mode === 'slap') {
|
|
// FIXME this doesn't take railroad curves into account, e.g. it thinks a
|
|
// rover can't push a block through a curve
|
|
if (tile.movement_cooldown > 0 ||
|
|
! this.check_movement(tile, tile.cell, direction, push_mode))
|
|
{
|
|
return false;
|
|
}
|
|
else if (push_mode === 'slap') {
|
|
if (actor === this.player) {
|
|
this._set_tile_prop(actor, 'is_pushing', true);
|
|
this.sfx.play_once('push');
|
|
}
|
|
tile.decision = direction;
|
|
}
|
|
}
|
|
else if (push_mode === 'push') {
|
|
if (actor === this.player) {
|
|
this._set_tile_prop(actor, 'is_pushing', true);
|
|
}
|
|
// We can't directly push a sliding block, even one on a force floor that's
|
|
// stuck on a wall. Instead, it becomes a pending move for the block, which
|
|
// will use this as a decision next time it's allowed to move
|
|
// FIXME this is clumsy and creates behavior dependent on actor order. my
|
|
// original implementation only did this if the push /failed/; is that worth
|
|
// a compat option? also, how does any of this work under lynx rules?
|
|
if (tile.slide_mode === 'force' ||
|
|
(tile.slide_mode !== null && tile.movement_cooldown > 0))
|
|
{
|
|
this._set_tile_prop(tile, 'pending_push', direction);
|
|
// FIXME if the block has already made a decision then this is necessary
|
|
// to override it. but i don't like it; (a) it might cause blocks to
|
|
// get stuck against walls on force floors, because the code to fix that
|
|
// is at decision time; (b) it's done for pulling too and just feels
|
|
// hacky?
|
|
tile.decision = direction;
|
|
return false;
|
|
}
|
|
|
|
if (this.attempt_out_of_turn_step(tile, direction)) {
|
|
if (actor === this.player) {
|
|
this.sfx.play_once('push');
|
|
}
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
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.
|
|
// 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)
|
|
if (this.compat.no_backwards_override && actor === this.player &&
|
|
actor.slide_mode === 'force' && direction === DIRECTIONS[actor.direction].opposite)
|
|
{
|
|
return false;
|
|
}
|
|
|
|
let dest_cell = this.get_neighboring_cell(orig_cell, direction);
|
|
if (! dest_cell) {
|
|
if (push_mode === 'push') {
|
|
if (actor.type.on_blocked) {
|
|
actor.type.on_blocked(actor, this, direction, null);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let success = (
|
|
this.can_actor_leave_cell(actor, orig_cell, direction, push_mode) &&
|
|
this.can_actor_enter_cell(actor, dest_cell, direction, push_mode));
|
|
|
|
// If we have the hook, pull anything behind us, now that we're out of the way.
|
|
// In CC2, this has to happen here to make hook-slapping work and allow hooking a moving
|
|
// block to stop us, and it has to use pending decisions rather than an immediate move
|
|
// because we're still in the way (so the block can't move) and also to prevent a block from
|
|
// being able to follow us through a swivel (which we haven't swiveled at decision time).
|
|
if (this.compat.use_legacy_hooking && success && actor.has_item('hook')) {
|
|
let behind_cell = this.get_neighboring_cell(orig_cell, DIRECTIONS[direction].opposite);
|
|
if (behind_cell) {
|
|
let behind_actor = behind_cell.get_actor();
|
|
if (behind_actor && actor.can_pull(behind_actor, direction)) {
|
|
if (behind_actor.movement_cooldown) {
|
|
return false;
|
|
}
|
|
else if (behind_actor.type.is_block && push_mode === 'push') {
|
|
this._set_tile_prop(behind_actor, 'is_pulled', true);
|
|
this._set_tile_prop(behind_actor, 'pending_push', direction);
|
|
behind_actor.decision = direction;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
// Try to move the given actor one tile in the given direction and update their cooldown.
|
|
// Return true if successful.
|
|
attempt_step(actor, direction) {
|
|
// In mid-movement, we can't even change direction!
|
|
if (actor.movement_cooldown > 0)
|
|
return false;
|
|
|
|
let redirected_direction = actor.cell.redirect_exit(actor, direction);
|
|
if (direction !== redirected_direction) {
|
|
// Some tiles (ahem, frame blocks) rotate when their attempted direction is redirected
|
|
if (actor.type.on_rotate) {
|
|
let turn = ['right', 'left', 'opposite'].filter(t => {
|
|
return DIRECTIONS[direction][t] === redirected_direction;
|
|
})[0];
|
|
actor.type.on_rotate(actor, this, turn);
|
|
}
|
|
|
|
direction = redirected_direction;
|
|
}
|
|
|
|
// Grab speed /first/, in case the movement or on_blocked turns us into an animation
|
|
// immediately (and then we won't have a speed!)
|
|
// FIXME that's a weird case actually since the explosion ends up still moving
|
|
let speed = actor.type.movement_speed;
|
|
|
|
let success = this.check_movement(actor, actor.cell, direction, 'push');
|
|
// Only set direction after checking movement; check_movement needs it for preventing
|
|
// backwards overriding in Lynx
|
|
this.set_actor_direction(actor, direction);
|
|
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 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;
|
|
}
|
|
//speed boots speed us up UNLESS we're entering a terrain with a speed factor and an unignored slide mode (so e.g. we gain 2x on teleports, ice + ice skates, force floors + suction boots, sand and dash floors, but we don't gain 2x sliding on ice or force floors unless it's the turn we're leaving them)
|
|
if (actor.has_item('speed_boots')
|
|
&& !(terrain.type.speed_factor && terrain.type.slide_mode && !actor.ignores(terrain.type.name) && !actor.slide_ignores(terrain.type.name)))
|
|
{
|
|
speed /= 2;
|
|
}
|
|
|
|
let orig_cell = actor.cell;
|
|
this._set_tile_prop(actor, 'previous_cell', orig_cell);
|
|
let duration = speed * 3;
|
|
this._set_tile_prop(actor, 'movement_cooldown', duration);
|
|
this._set_tile_prop(actor, 'movement_speed', duration);
|
|
this.move_to(actor, goal_cell);
|
|
|
|
// Do Lexy-style hooking here: only attempt to pull things just after we've actually moved
|
|
// successfully, which means the hook can never stop us from moving and hook slapping is not
|
|
// a thing, and also make them a real move rather than a weird pending thing
|
|
if (! this.compat.use_legacy_hooking && actor.has_item('hook')) {
|
|
let behind_cell = this.get_neighboring_cell(orig_cell, DIRECTIONS[direction].opposite);
|
|
if (behind_cell) {
|
|
let behind_actor = behind_cell.get_actor();
|
|
if (behind_actor && actor.can_pull(behind_actor, direction) && behind_actor.type.is_block) {
|
|
this._set_tile_prop(behind_actor, 'is_pulled', true);
|
|
this.attempt_out_of_turn_step(behind_actor, direction);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (actor === this.player) {
|
|
actor.last_blocked_direction = null;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
attempt_out_of_turn_step(actor, direction) {
|
|
if (actor.slide_mode === 'turntable') {
|
|
// Something is (e.g.) pushing a block that just landed on a turntable and is waiting to
|
|
// slide out of it. Ignore the push direction and move in its current direction;
|
|
// otherwise a player will push a block straight through, then turn, which sucks
|
|
direction = actor.direction;
|
|
}
|
|
let success = this.attempt_step(actor, direction);
|
|
if (success) {
|
|
this._do_extra_cooldown(actor);
|
|
}
|
|
return success;
|
|
}
|
|
|
|
_do_extra_cooldown(actor) {
|
|
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);
|
|
}
|
|
}
|
|
|
|
// Move the given actor to the given position and perform any appropriate
|
|
// tile interactions. Does NOT check for whether the move is actually
|
|
// legal; use attempt_step for that!
|
|
move_to(actor, goal_cell) {
|
|
if (actor.cell === goal_cell)
|
|
return;
|
|
|
|
if (actor.type.on_starting_move) {
|
|
actor.type.on_starting_move(actor, this);
|
|
}
|
|
|
|
let original_cell = actor.cell;
|
|
// Physically remove the actor first, so that it won't get in the way of e.g. a splash
|
|
// spawned from stepping off of a lilypad
|
|
this.remove_tile(actor, true);
|
|
|
|
// Announce we're leaving, for the handful of tiles that care about it
|
|
for (let tile of original_cell) {
|
|
if (! tile)
|
|
continue;
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
|
|
// FIXME ah, stepping off a lilypad will add a splash but we're still here? but then
|
|
// why did the warning not catch it
|
|
if (tile.type.on_depart) {
|
|
tile.type.on_depart(tile, this, actor);
|
|
}
|
|
}
|
|
|
|
// Announce we're approaching. Slide mode is set here, since it's about the tile we're
|
|
// moving towards and needs to last through our next decision
|
|
this.make_slide(actor, null);
|
|
for (let tile of goal_cell) {
|
|
if (! tile)
|
|
continue;
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
if (actor.slide_ignores(tile.type.name))
|
|
continue;
|
|
|
|
if (tile.type.on_approach) {
|
|
tile.type.on_approach(tile, this, actor);
|
|
}
|
|
if (tile.type.slide_mode) {
|
|
this.make_slide(actor, tile.type.slide_mode);
|
|
}
|
|
}
|
|
|
|
// Now add the actor back; we have to wait this long because e.g. monsters erase splashes
|
|
if (goal_cell.get_actor()) {
|
|
// FIXME a monster or block killing the player will still move into her cell!!! i don't
|
|
// know what to do about this, i feel like i tried making monster/player block each
|
|
// other before and it did not go well. maybe it was an ordering issue though?
|
|
this.add_tile(actor, original_cell);
|
|
return;
|
|
}
|
|
else {
|
|
this.add_tile(actor, goal_cell);
|
|
}
|
|
|
|
if (this.compat.tiles_react_instantly) {
|
|
this.step_on_cell(actor, actor.cell);
|
|
}
|
|
}
|
|
|
|
// Step on every tile in a cell we just arrived in
|
|
step_on_cell(actor, cell) {
|
|
if (actor.type.on_finishing_move) {
|
|
actor.type.on_finishing_move(actor, this);
|
|
}
|
|
|
|
// Step on topmost things first -- notably, it's safe to step on water with flippers on top
|
|
// TODO is there a custom order here similar to collision checking?
|
|
for (let layer = LAYERS.MAX - 1; layer >= 0; layer--) {
|
|
let tile = cell[layer];
|
|
if (! tile)
|
|
continue;
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
|
|
if (tile.type.teleport_dest_order) {
|
|
// This is used by an extra pass just after our caller, so it doesn't need to undo.
|
|
// It DOES need to happen before items, though, or yellow teleporters never work!
|
|
actor.just_stepped_on_teleporter = tile;
|
|
}
|
|
else if (tile.type.item_priority !== undefined) {
|
|
// Possibly try to pick items up
|
|
// TODO maybe this should be a method
|
|
let mod = cell.get_item_mod();
|
|
let try_pickup = (
|
|
tile.type.item_priority >= actor.type.item_pickup_priority ||
|
|
(mod && mod.type.item_modifier === 'pickup'));
|
|
if (this.compat.monsters_ignore_keys && tile.type.is_key && actor.type.is_monster) {
|
|
try_pickup = false;
|
|
}
|
|
if (try_pickup && this.attempt_take(actor, tile)) {
|
|
if (tile.type.is_key) {
|
|
this.sfx.play_once('get-key', cell);
|
|
}
|
|
else if (tile.type.is_item) {
|
|
this.sfx.play_once('get-tool', cell);
|
|
}
|
|
continue;
|
|
}
|
|
}
|
|
|
|
else if (tile.type.on_arrive) {
|
|
tile.type.on_arrive(tile, this, actor);
|
|
}
|
|
}
|
|
|
|
// Play step sound
|
|
if (actor === this.player) {
|
|
let terrain = cell.get_terrain();
|
|
if (actor.slide_mode === 'ice') {
|
|
this.sfx.play_once('slide-ice');
|
|
}
|
|
else if (actor.slide_mode === 'force') {
|
|
this.sfx.play_once('slide-force');
|
|
}
|
|
else if (terrain.type.name === 'popdown_floor') {
|
|
this.sfx.play_once('step-popdown');
|
|
}
|
|
else if (terrain.type.name === 'gravel' || terrain.type.name === 'railroad') {
|
|
this.sfx.play_once('step-gravel');
|
|
}
|
|
else if (terrain.type.name === 'water') {
|
|
if (actor.ignores(terrain.type.name)) {
|
|
this.sfx.play_once('step-water');
|
|
}
|
|
}
|
|
else if (terrain.type.name === 'fire') {
|
|
if (actor.has_item('fire_boots')) {
|
|
this.sfx.play_once('step-fire');
|
|
}
|
|
}
|
|
else if (terrain.type.slide_mode === 'force') {
|
|
this.sfx.play_once('step-force');
|
|
}
|
|
else if (terrain.type.slide_mode === 'ice') {
|
|
this.sfx.play_once('step-ice');
|
|
}
|
|
else {
|
|
this.sfx.play_once('step-floor');
|
|
}
|
|
}
|
|
}
|
|
|
|
attempt_teleport(actor) {
|
|
let teleporter = actor.just_stepped_on_teleporter;
|
|
delete actor.just_stepped_on_teleporter;
|
|
|
|
// We're about to abruptly move the actor, and the renderer needs to know to interpolate its
|
|
// movement towards the teleporter it just stepped on, not the teleporter it's moved to
|
|
this._set_tile_prop(actor, 'destination_cell', actor.cell);
|
|
|
|
if (teleporter.type.name === 'teleport_red' && ! teleporter.is_active) {
|
|
// Curious special-case red teleporter behavior: if you pass through a wired but
|
|
// inactive one, you keep sliding indefinitely. Players can override out of it, but
|
|
// other actors cannot. (Normally, a teleport slide ends after one decision phase.)
|
|
// XXX this is useful when the exit is briefly blocked, but it can also get monsters
|
|
// stuck forever :(
|
|
this.make_slide(actor, 'teleport-forever');
|
|
// Also, there's no sound and whatnot, so everything else is skipped outright.
|
|
return;
|
|
}
|
|
|
|
// Explicitly set us as teleport sliding, since in some very obscure cases (auto-dropping a
|
|
// yellow teleporter because you picked up an item with a full inventory and immediately
|
|
// teleporting through it) it may not have been applied
|
|
this.make_slide(actor, 'teleport');
|
|
|
|
let success = false;
|
|
let dest, direction;
|
|
for ([dest, direction] of teleporter.type.teleport_dest_order(teleporter, this, actor)) {
|
|
// Teleporters already containing an actor are blocked and unusable
|
|
// FIXME should check collision? otherwise this catches non-blocking vfx...
|
|
if (dest.cell.some(tile => tile && tile.type.is_actor && tile !== actor && ! tile.type.ttl))
|
|
continue;
|
|
|
|
// XXX lynx treats this as a slide and does it in a pass in the main loop
|
|
|
|
// FIXME bleugh hardcode
|
|
if (dest === teleporter && teleporter.type.name === 'teleport_yellow') {
|
|
break;
|
|
}
|
|
// Note that this uses 'bump' even for players; it would be very bad if we could
|
|
// initiate movement in this pass (in Lexy rules, anyway), because we might try to push
|
|
// something that's still waiting to teleport itself!
|
|
// XXX is this correct? it does mean you won't try to teleport to a teleporter that's
|
|
// "blocked" by a block that won't be there anyway by the time you try to move, but that
|
|
// seems very obscure and i haven't run into a case with it yet. offhand i don't think
|
|
// it can even come up under cc2 rules, since teleporting is done after an actor cools
|
|
// down and before the next actor even gets a chance to act
|
|
if (this.check_movement(actor, dest.cell, direction, 'bump')) {
|
|
success = true;
|
|
// Sound plays from the origin cell simply because that's where the sfx player
|
|
// thinks the player is currently; position isn't updated til next turn
|
|
this.sfx.play_once('teleport', teleporter.cell);
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! success) {
|
|
if (teleporter.type.item_priority !== undefined &&
|
|
teleporter.type.item_priority >= actor.type.item_pickup_priority &&
|
|
this.allow_taking_yellow_teleporters)
|
|
{
|
|
// Super duper special yellow teleporter behavior: you pick it the fuck up
|
|
this.make_slide(actor, null);
|
|
this.attempt_take(actor, teleporter);
|
|
if (actor === this.player) {
|
|
this.sfx.play_once('get-tool', teleporter.cell);
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
|
|
this.set_actor_direction(actor, direction);
|
|
|
|
this.spawn_animation(actor.cell, 'teleport_flash');
|
|
if (dest.cell !== actor.cell) {
|
|
this.spawn_animation(dest.cell, 'teleport_flash');
|
|
}
|
|
|
|
// Now physically move the actor, but their movement waits until next decision phase
|
|
this.remove_tile(actor, true);
|
|
this.add_tile(actor, dest.cell);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
// Inventory handling -----------------------------------------------------------------------------
|
|
|
|
cycle_inventory(actor) {
|
|
if (this.stored_level.use_cc1_boots)
|
|
return;
|
|
if (actor.movement_cooldown > 0)
|
|
return;
|
|
|
|
// Cycle leftwards, i.e., the oldest item moves to the end of the list
|
|
if (actor.toolbelt && actor.toolbelt.length > 1) {
|
|
actor.toolbelt.push(actor.toolbelt.shift());
|
|
this._push_pending_undo(() => actor.toolbelt.unshift(actor.toolbelt.pop()));
|
|
}
|
|
}
|
|
|
|
drop_item(actor) {
|
|
if (this.stored_level.use_cc1_boots)
|
|
return false;
|
|
if (actor.movement_cooldown > 0)
|
|
return false;
|
|
if (! actor.toolbelt || actor.toolbelt.length === 0)
|
|
return false;
|
|
|
|
// Drop the oldest item, i.e. the first one
|
|
let name = actor.toolbelt[0];
|
|
if (this._place_dropped_item(name, actor.cell, actor)) {
|
|
actor.toolbelt.shift();
|
|
this._push_pending_undo(() => actor.toolbelt.unshift(name));
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Attempt to place an item in the world, as though dropped by an actor
|
|
_place_dropped_item(name, cell, dropping_actor) {
|
|
let type = TILE_TYPES[name];
|
|
if (type.layer === LAYERS.terrain) {
|
|
// Terrain items (i.e., yellow teleports) can only be dropped on regular floor
|
|
let terrain = cell.get_terrain();
|
|
if (terrain.type.name !== 'floor')
|
|
return false;
|
|
|
|
this.transmute_tile(terrain, name);
|
|
}
|
|
else {
|
|
// Note that we can't drop a bowling ball if there's already an item, even though a
|
|
// dropped bowling ball is really an actor
|
|
if (cell.get_item())
|
|
return false;
|
|
|
|
if (type.on_drop) {
|
|
// FIXME quirky things happen if a dropped bowling ball can't enter the facing cell
|
|
// (mostly it disappears) (also arguably a bug)
|
|
// FIXME does this even need to be a function lol
|
|
name = type.on_drop(this);
|
|
if (name) {
|
|
type = TILE_TYPES[name];
|
|
}
|
|
}
|
|
let tile = new Tile(type);
|
|
if (type.is_actor) {
|
|
// This is tricky -- the item has become an actor, but whatever dropped it is
|
|
// already in this cell's actor layer. But we also know for sure that there's no
|
|
// item in this cell, so we'll cheat a little: remove the dropping actor, set the
|
|
// item moving, then put the dropping actor back before anyone notices.
|
|
this.remove_tile(dropping_actor);
|
|
this.add_tile(tile, cell);
|
|
if (! this.attempt_out_of_turn_step(tile, dropping_actor.direction)) {
|
|
// It was unable to move; if it exploded, we have a special non-blocking VFX for
|
|
// that, but otherwise there's nothing we can do but erase it (as CC2 does)
|
|
if (tile.type.name === 'explosion') {
|
|
this.transmute_tile(tile, 'explosion_nb', true);
|
|
}
|
|
else {
|
|
this.remove_tile(tile);
|
|
}
|
|
}
|
|
if (tile.cell) {
|
|
this.add_actor(tile);
|
|
}
|
|
this.add_tile(dropping_actor, cell);
|
|
}
|
|
else {
|
|
this.add_tile(tile, cell);
|
|
}
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Wiring -----------------------------------------------------------------------------------------
|
|
|
|
_do_wire_phase() {
|
|
let force_next_wire_phase = false;
|
|
if (this.recalculate_circuitry_next_wire_phase)
|
|
{
|
|
this.recalculate_circuitry();
|
|
this.recalculate_circuitry_next_wire_phase = false;
|
|
force_next_wire_phase = true;
|
|
}
|
|
|
|
if (this.circuits.length === 0)
|
|
return;
|
|
|
|
// Prepare a big slab of undo. The only thing we directly change here (aside from
|
|
// emitting_edges, a normal tile property) is Tile.powered_edges, which tends to change for
|
|
// large numbers of tiles at a time, so store it all in one map and undo it in one shot.
|
|
let powered_edges_changes = new Map;
|
|
let _set_edges = (tile, new_edges) => {
|
|
if (this.undo_enabled) {
|
|
if (powered_edges_changes.has(tile)) {
|
|
if (powered_edges_changes.get(tile) === new_edges) {
|
|
powered_edges_changes.delete(tile);
|
|
}
|
|
}
|
|
else {
|
|
powered_edges_changes.set(tile, tile.powered_edges);
|
|
}
|
|
}
|
|
tile.powered_edges = new_edges;
|
|
};
|
|
let power_edges = (tile, edges) => {
|
|
let new_edges = tile.powered_edges | edges;
|
|
_set_edges(tile, new_edges);
|
|
};
|
|
let depower_edges = (tile, edges) => {
|
|
let new_edges = tile.powered_edges & ~edges;
|
|
_set_edges(tile, new_edges);
|
|
};
|
|
|
|
// Update the state of any tiles that can generate power. If none of them changed since
|
|
// last wiring update, stop here. First, static power sources.
|
|
let any_changed = false;
|
|
for (let tile of this.power_sources) {
|
|
if (! tile.cell)
|
|
continue;
|
|
let emitting = 0;
|
|
if (tile.type.get_emitting_edges) {
|
|
// This method may not exist any more, if the tile was destroyed by e.g. dynamite
|
|
emitting = tile.type.get_emitting_edges(tile, this);
|
|
}
|
|
if (emitting !== tile.emitting_edges) {
|
|
any_changed = true;
|
|
this._set_tile_prop(tile, 'emitting_edges', emitting);
|
|
}
|
|
}
|
|
// Next, actors who are standing still, on floor/electrified, and holding a lightning bolt
|
|
let externally_powered_circuits = new Set;
|
|
for (let actor of this.actors) {
|
|
if (! actor.cell)
|
|
continue;
|
|
let emitting = 0;
|
|
if (actor.movement_cooldown === 0 && actor.has_item('lightning_bolt')) {
|
|
let wired_tile = actor.cell.get_wired_tile();
|
|
if (wired_tile && (wired_tile === actor || wired_tile.type.name === 'floor' || wired_tile.type.name === 'electrified_floor')) {
|
|
emitting = wired_tile.wire_directions;
|
|
for (let circuit of wired_tile.circuits) {
|
|
if (circuit) {
|
|
externally_powered_circuits.add(circuit);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if (emitting !== actor.emitting_edges) {
|
|
any_changed = true;
|
|
this._set_tile_prop(actor, 'emitting_edges', emitting);
|
|
}
|
|
}
|
|
|
|
if (! any_changed && !force_next_wire_phase) {
|
|
return;
|
|
}
|
|
|
|
for (let tile of this.wired_outputs) {
|
|
// This is only used within this function, no need to undo
|
|
// TODO if this can overlap with power_sources then this is too late?
|
|
tile._prev_powered_edges = tile.powered_edges;
|
|
}
|
|
|
|
// Now go through every circuit, compute whether it's powered, and if that changed, inform
|
|
// its outputs
|
|
let circuit_changes = new Map;
|
|
for (let circuit of this.circuits) {
|
|
let is_powered = false;
|
|
|
|
if (externally_powered_circuits.has(circuit)) {
|
|
is_powered = true;
|
|
}
|
|
else {
|
|
for (let [input_tile, edges] of circuit.inputs.entries()) {
|
|
if (input_tile.emitting_edges & edges) {
|
|
is_powered = true;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
let was_powered = circuit.is_powered;
|
|
if (is_powered === was_powered)
|
|
continue;
|
|
|
|
circuit.is_powered = is_powered;
|
|
if (this.undo_enabled) {
|
|
circuit_changes.set(circuit, was_powered);
|
|
}
|
|
|
|
for (let [tile, edges] of circuit.tiles.entries()) {
|
|
if (is_powered) {
|
|
power_edges(tile, edges);
|
|
}
|
|
else {
|
|
depower_edges(tile, edges);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (let tile of this.wired_outputs) {
|
|
if (tile.powered_edges && ! tile._prev_powered_edges && tile.type.on_power) {
|
|
tile.type.on_power(tile, this);
|
|
}
|
|
else if (! tile.powered_edges && tile._prev_powered_edges && tile.type.on_depower) {
|
|
tile.type.on_depower(tile, this);
|
|
}
|
|
}
|
|
|
|
this._push_pending_undo(() => {
|
|
for (let [tile, edges] of powered_edges_changes.entries()) {
|
|
tile.powered_edges = edges;
|
|
}
|
|
for (let [circuit, is_powered] of circuit_changes.entries()) {
|
|
circuit.is_powered = is_powered;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Level inspection -------------------------------------------------------------------------------
|
|
|
|
get_neighboring_cell(cell, direction) {
|
|
let move = DIRECTIONS[direction].movement;
|
|
return this.cell(cell.x + move[0], cell.y + move[1]);
|
|
}
|
|
|
|
// Iterates over the grid in (reverse?) reading order and yields all tiles with the given name.
|
|
// The starting cell is iterated last.
|
|
*iter_tiles_in_reading_order(start_cell, name, reverse = false) {
|
|
let i = this.coords_to_scalar(start_cell.x, start_cell.y);
|
|
let index = TILE_TYPES[name].layer;
|
|
while (true) {
|
|
if (reverse) {
|
|
i -= 1;
|
|
if (i < 0) {
|
|
i += this.size_x * this.size_y;
|
|
}
|
|
}
|
|
else {
|
|
i += 1;
|
|
i %= this.size_x * this.size_y;
|
|
}
|
|
|
|
let cell = this.linear_cells[i];
|
|
let tile = cell[index];
|
|
if (tile && tile.type.name === name) {
|
|
yield tile;
|
|
}
|
|
|
|
if (cell === start_cell)
|
|
return;
|
|
}
|
|
}
|
|
|
|
//same as above, but accepts multiple tiles
|
|
*iter_tiles_in_reading_order_multiple(start_cell, names, reverse = false) {
|
|
let i = this.coords_to_scalar(start_cell.x, start_cell.y);
|
|
let index = TILE_TYPES[names[0]].layer;
|
|
while (true) {
|
|
if (reverse) {
|
|
i -= 1;
|
|
if (i < 0) {
|
|
i += this.size_x * this.size_y;
|
|
}
|
|
}
|
|
else {
|
|
i += 1;
|
|
i %= this.size_x * this.size_y;
|
|
}
|
|
|
|
let cell = this.linear_cells[i];
|
|
let tile = cell[index];
|
|
if (tile && names.indexOf(tile.type.name) >= 0) {
|
|
yield tile;
|
|
}
|
|
|
|
if (cell === start_cell)
|
|
return;
|
|
}
|
|
}
|
|
|
|
// FIXME require_stub should really just care whether we ourselves /can/ contain wire, and also
|
|
// we should check that on our neighbor
|
|
is_tile_wired(tile, require_stub = true) {
|
|
for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) {
|
|
if (require_stub && (tile.wire_directions & dirinfo.bit) === 0)
|
|
continue;
|
|
|
|
let neighbor = this.get_neighboring_cell(tile.cell, direction);
|
|
if (! neighbor)
|
|
continue;
|
|
|
|
let terrain = neighbor.get_terrain();
|
|
if (terrain.type.name === 'logic_gate' &&
|
|
terrain.type.get_wires(terrain).includes(dirinfo.opposite))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
let wired = neighbor.get_wired_tile();
|
|
if (! wired)
|
|
continue;
|
|
|
|
if (wired.type.wire_propagation_mode === 'none' && ! wired.type.is_power_source)
|
|
// Being next to e.g. a red teleporter doesn't count (but pink button is ok)
|
|
continue;
|
|
|
|
if ((wired.wire_directions & dirinfo.opposite_bit) &&
|
|
! (wired.wire_tunnel_directions & dirinfo.opposite_bit))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Undo/redo --------------------------------------------------------------------------------------
|
|
|
|
create_undo_entry() {
|
|
let entry = [];
|
|
entry.tile_changes = new Map;
|
|
entry.level_props = {};
|
|
return entry;
|
|
}
|
|
|
|
has_undo() {
|
|
let prev_index = this.undo_buffer_index - 1;
|
|
if (prev_index < 0) {
|
|
prev_index += UNDO_BUFFER_SIZE;
|
|
}
|
|
|
|
return this.undo_buffer[prev_index] !== null;
|
|
}
|
|
|
|
commit() {
|
|
if (! this.undo_enabled) {
|
|
return;
|
|
}
|
|
this.undo_buffer[this.undo_buffer_index] = this.pending_undo;
|
|
this.pending_undo = this.create_undo_entry();
|
|
|
|
this.undo_buffer_index += 1;
|
|
this.undo_buffer_index %= UNDO_BUFFER_SIZE;
|
|
}
|
|
|
|
undo() {
|
|
this.aid = Math.max(1, this.aid);
|
|
|
|
// In turn-based mode, we might still be in mid-tic with a partial undo stack; do that first
|
|
this._undo_entry(this.pending_undo);
|
|
this.pending_undo = this.create_undo_entry();
|
|
|
|
this.undo_buffer_index -= 1;
|
|
if (this.undo_buffer_index < 0) {
|
|
this.undo_buffer_index += UNDO_BUFFER_SIZE;
|
|
}
|
|
this._undo_entry(this.undo_buffer[this.undo_buffer_index]);
|
|
this.undo_buffer[this.undo_buffer_index] = null;
|
|
|
|
if (this.undid_past_recalculate_circuitry) {
|
|
this.recalculate_circuitry_next_wire_phase = true;
|
|
this.undid_past_recalculate_circuitry = false;
|
|
}
|
|
}
|
|
|
|
// Reverse a single undo entry
|
|
_undo_entry(entry) {
|
|
if (! entry) {
|
|
return;
|
|
}
|
|
|
|
// Undo in reverse order! There's no redo, so it's okay to destroy this
|
|
entry.reverse();
|
|
for (let undo of entry) {
|
|
undo();
|
|
}
|
|
for (let [tile, changes] of entry.tile_changes) {
|
|
for (let [key, value] of changes) {
|
|
tile[key] = value;
|
|
}
|
|
}
|
|
for (let [key, value] of Object.entries(entry.level_props)) {
|
|
this[key] = value;
|
|
}
|
|
}
|
|
|
|
_push_pending_undo(thunk) {
|
|
if (this.undo_enabled) {
|
|
this.pending_undo.push(thunk)
|
|
}
|
|
}
|
|
|
|
// Level alteration -------------------------------------------------------------------------------
|
|
// EVERYTHING that changes the state of a level, including the state of a single tile, should do
|
|
// it through one of these for undo/rewind purposes
|
|
|
|
_set_tile_prop(tile, key, val) {
|
|
if (Number.isNaN(val)) throw new Error(`got a NaN for ${key} on ${tile.type.name} at ${tile.cell.x}, ${tile.cell.y}`);
|
|
if (! this.undo_enabled) {
|
|
tile[key] = val;
|
|
return;
|
|
}
|
|
if (tile[key] === val)
|
|
return;
|
|
|
|
let changes = this.pending_undo.tile_changes.get(tile);
|
|
if (! changes) {
|
|
changes = new Map;
|
|
this.pending_undo.tile_changes.set(tile, changes);
|
|
}
|
|
|
|
// If we haven't yet done so, log the original value
|
|
if (! changes.has(key)) {
|
|
changes.set(key, tile[key]);
|
|
}
|
|
// If there's an original value already logged, and it's the value we're about to change
|
|
// back to, then delete the change
|
|
else if (changes.get(key) === val) {
|
|
changes.delete(key);
|
|
}
|
|
|
|
tile[key] = val;
|
|
}
|
|
|
|
collect_chip() {
|
|
if (this.chips_remaining > 0) {
|
|
if (this.chips_remaining > 1) {
|
|
this.sfx.play_once('get-chip');
|
|
}
|
|
else {
|
|
this.sfx.play_once('get-chip-last');
|
|
}
|
|
this.chips_remaining--;
|
|
}
|
|
else {
|
|
this.sfx.play_once('get-chip-extra');
|
|
}
|
|
}
|
|
|
|
adjust_bonus(add, mult = 1) {
|
|
this.bonus_points = Math.floor(this.bonus_points * mult) + add;
|
|
}
|
|
|
|
pause_timer() {
|
|
if (this.time_remaining === null)
|
|
return;
|
|
|
|
this.timer_paused = ! this.timer_paused;
|
|
}
|
|
|
|
adjust_timer(dt) {
|
|
// Untimed levels become timed levels with 0 seconds remaining
|
|
this.time_remaining = Math.max(0, (this.time_remaining ?? 0) + dt * 20);
|
|
if (this.time_remaining <= 0) {
|
|
// If the timer isn't paused, this will kill the player at the end of the tic
|
|
this.time_remaining = 1;
|
|
}
|
|
}
|
|
|
|
kill_actor(actor, killer, animation_name = null, sfx = null, fail_reason = null) {
|
|
if (actor.type.is_real_player) {
|
|
// Resurrect using the ankh tile, if possible
|
|
if (this.ankh_tile) {
|
|
let ankh_cell = this.ankh_tile.cell;
|
|
let existing_actor = ankh_cell.get_actor();
|
|
if (! existing_actor) {
|
|
// FIXME water should still splash, etc
|
|
this.sfx.play_once('revive');
|
|
|
|
this._set_tile_prop(actor, 'movement_cooldown', null);
|
|
this._set_tile_prop(actor, 'movement_speed', null);
|
|
this.make_slide(actor, null);
|
|
this.move_to(actor, ankh_cell);
|
|
|
|
this.transmute_tile(this.ankh_tile, 'floor');
|
|
this.spawn_animation(ankh_cell, 'resurrection');
|
|
let old_tile = this.ankh_tile;
|
|
this.ankh_tile = null;
|
|
this._push_pending_undo(() => {
|
|
this.ankh_tile = old_tile;
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Otherwise, lose the game
|
|
this.fail(fail_reason || killer.type.name, killer, actor);
|
|
return;
|
|
}
|
|
|
|
if (actor.type.on_death) {
|
|
actor.type.on_death(actor, this);
|
|
}
|
|
|
|
if (sfx) {
|
|
this.sfx.play_once(sfx, actor.cell);
|
|
}
|
|
if (animation_name) {
|
|
this.transmute_tile(actor, animation_name);
|
|
}
|
|
else {
|
|
this.remove_tile(actor);
|
|
}
|
|
}
|
|
|
|
fail(reason, killer = null, player = null) {
|
|
if (this.state !== 'playing')
|
|
return;
|
|
|
|
if (player === null) {
|
|
player = this.player;
|
|
}
|
|
|
|
if (reason === 'time') {
|
|
this.sfx.play_once('timeup');
|
|
}
|
|
else {
|
|
this.sfx.play_once('lose');
|
|
}
|
|
|
|
this._push_pending_undo(() => {
|
|
this.fail_reason = null;
|
|
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() {
|
|
if (this.state !== 'playing')
|
|
return;
|
|
|
|
this.sfx.play_once('win');
|
|
this.state = 'success';
|
|
this._set_tile_prop(this.player, 'exited', true);
|
|
}
|
|
|
|
get_scorecard() {
|
|
if (this.state !== 'success') {
|
|
return null;
|
|
}
|
|
|
|
// FIXME should probably remember tics here, not just seconds?
|
|
let time = Math.ceil((this.time_remaining ?? 0) / TICS_PER_SECOND);
|
|
return {
|
|
time: time,
|
|
abstime: this.tic_counter,
|
|
bonus: this.bonus_points,
|
|
score: this.stored_level.number * 500 + time * 10 + this.bonus_points,
|
|
aid: this.aid,
|
|
};
|
|
}
|
|
|
|
// Get the next direction a random force floor will use. They share global
|
|
// state and cycle clockwise.
|
|
get_force_floor_direction() {
|
|
if (this.compat.rff_actually_random) {
|
|
return DIRECTION_ORDER[this.prng() % 4];
|
|
}
|
|
|
|
let d = this.force_floor_direction;
|
|
this.force_floor_direction = DIRECTIONS[d].right;
|
|
return d;
|
|
}
|
|
|
|
// Tile stuff in particular
|
|
// TODO should add in the right layer? maybe? hard to say what that is when mscc levels might
|
|
// have things stacked in a weird order though
|
|
// TODO would be nice to make these not be closures but order matters much more here
|
|
|
|
remove_tile(tile) {
|
|
let cell = tile.cell;
|
|
cell._remove(tile);
|
|
this._push_pending_undo(() => cell._add(tile));
|
|
}
|
|
|
|
add_tile(tile, cell) {
|
|
cell._add(tile);
|
|
this._push_pending_undo(() => cell._remove(tile));
|
|
}
|
|
|
|
add_actor(actor) {
|
|
if (this.compat.reuse_actor_slots && actor.type.layer !== LAYERS.vfx) {
|
|
// Place the new actor in the first slot taken up by a nonexistent one, but not VFX
|
|
// which aren't supposed to impact gameplay
|
|
for (let i = 0, l = this.actors.length; i < l; i++) {
|
|
let old_actor = this.actors[i];
|
|
if (old_actor !== this.player && ! old_actor.cell) {
|
|
this.actors[i] = actor;
|
|
this._push_pending_undo(() => this.actors[i] = old_actor);
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
this.actors.push(actor);
|
|
this._push_pending_undo(() => this.actors.pop());
|
|
}
|
|
|
|
_init_animation(tile) {
|
|
// Co-opt movement_cooldown/speed for these despite that they aren't moving, since those
|
|
// properties are also used to animate everything else anyway. Decrement the cooldown
|
|
// immediately, as Lynx does; note that Lynx also ticks /and destroys/ animations early in
|
|
// the decision phase, but this seems to work out just as well
|
|
let duration = tile.type.ttl;
|
|
if (this.compat.force_lynx_animation_lengths) {
|
|
// Lynx animation duration is 12 tics, but it drops one if necessary to make the
|
|
// animation end on an odd tic (???) and that takes step parity into account
|
|
// because I guess it uses the global clock (?????????????????). Also, unlike CC2, Lynx
|
|
// animations are removed once their cooldown goes BELOW zero, so to simulate that we
|
|
// make the animation one tic longer.
|
|
// XXX wait am i sure that cc2 doesn't work that way too?
|
|
duration = (12 + (this.tic_counter + this.step_parity) % 2) * 3;
|
|
}
|
|
this._set_tile_prop(tile, 'movement_speed', duration);
|
|
this._set_tile_prop(tile, 'movement_cooldown', duration);
|
|
this._do_extra_cooldown(tile);
|
|
}
|
|
|
|
spawn_animation(cell, name) {
|
|
let type = TILE_TYPES[name];
|
|
// Spawned VFX silently erase any existing VFX
|
|
if (type.layer === LAYERS.vfx) {
|
|
let vfx = cell[type.layer];
|
|
if (vfx) {
|
|
this.remove_tile(vfx);
|
|
}
|
|
}
|
|
let tile = new Tile(type);
|
|
this._init_animation(tile);
|
|
// Note that newly-spawned animations don't have their cooldown decremented right away, and
|
|
// in the case of lilypads, this has gameplay implications. I can't just not tick them down
|
|
// or the renderer will break, so cheat by extending the duration by a tic
|
|
this._set_tile_prop(tile, 'movement_speed', tile.movement_speed + 3);
|
|
this._set_tile_prop(tile, 'movement_cooldown', tile.movement_cooldown + 3);
|
|
this.add_tile(tile, cell);
|
|
this.add_actor(tile);
|
|
}
|
|
|
|
transmute_tile(tile, name, force = false) {
|
|
if (tile.type.ttl && ! force) {
|
|
// If this is already an animation, don't turn it into a different one; this can happen
|
|
// if a block is pushed onto a cell containing both a mine and slime, both of which try
|
|
// to destroy it
|
|
return;
|
|
}
|
|
|
|
let old_type = tile.type;
|
|
let new_type = TILE_TYPES[name];
|
|
if (old_type.layer !== new_type.layer) {
|
|
// Move it to the right layer!
|
|
let cell = tile.cell;
|
|
cell._remove(tile);
|
|
tile.type = new_type;
|
|
cell._add(tile);
|
|
this._push_pending_undo(() => {
|
|
cell._remove(tile);
|
|
tile.type = old_type;
|
|
cell._add(tile);
|
|
});
|
|
}
|
|
else {
|
|
tile.type = new_type;
|
|
this._push_pending_undo(() => tile.type = old_type);
|
|
}
|
|
|
|
// For transmuting into an animation, set up the timer immediately
|
|
if (tile.type.ttl) {
|
|
if (! old_type.is_actor) {
|
|
console.warn("Transmuting a non-actor into an animation!");
|
|
}
|
|
// This is effectively a completely new object, so remove double cooldown prevention;
|
|
// the initial cooldown MUST happen, because the renderer can't handle cooldown == speed
|
|
if (tile.last_extra_cooldown_tic) {
|
|
this._set_tile_prop(tile, 'last_extra_cooldown_tic', null);
|
|
}
|
|
this._init_animation(tile);
|
|
this._set_tile_prop(tile, 'previous_cell', null);
|
|
this.make_slide(tile, null);
|
|
}
|
|
}
|
|
|
|
// Have an actor try to pick up a particular tile; it's prevented if there's a no sign, and the
|
|
// tile is removed if successful
|
|
// FIXME do not allow overflow dropping before picking up the new item
|
|
attempt_take(actor, tile) {
|
|
let cell = tile.cell;
|
|
let mod = cell.get_item_mod();
|
|
if (mod && mod.type.item_modifier === 'ignore')
|
|
return false;
|
|
|
|
// Some faux items have custom pickup behavior, e.g. chips and bonuses
|
|
if (tile.type.on_pickup) {
|
|
if (tile.type.on_pickup(tile, this, actor)) {
|
|
this.remove_tile(tile);
|
|
return true;
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Handling a full inventory is a teeny bit complicated. We want the following:
|
|
// - At no point are two items in the same cell
|
|
// - A yellow teleporter cannot be dropped in exchange for another yellow teleporter
|
|
// - If the oldest item can't be dropped, the pickup fails
|
|
// Thus we have to check whether dropping is possible FIRST, but only place the dropped item
|
|
// AFTER the pickup.
|
|
let dropped_item;
|
|
if (! tile.type.is_key && ! this.stored_level.use_cc1_boots &&
|
|
actor.toolbelt && actor.toolbelt.length >= 4)
|
|
{
|
|
let oldest_item_type = TILE_TYPES[actor.toolbelt[0]];
|
|
if (oldest_item_type.layer === LAYERS.terrain && cell.get_terrain().type.name !== 'floor') {
|
|
// This is a yellow teleporter, and we are not standing on floor; abort!
|
|
return false;
|
|
}
|
|
// Otherwise, it's either an item or a yellow teleporter we're allowed to drop, so steal
|
|
// it out of their inventory to be dropped later
|
|
dropped_item = actor.toolbelt.shift();
|
|
this._push_pending_undo(() => actor.toolbelt.unshift(dropped_item));
|
|
}
|
|
|
|
if (this.give_actor(actor, tile.type.name)) {
|
|
if (tile.type.layer === LAYERS.terrain) {
|
|
// This should only happen for the yellow teleporter
|
|
this.transmute_tile(tile, 'floor');
|
|
}
|
|
else {
|
|
this.remove_tile(tile);
|
|
}
|
|
if (mod && mod.type.item_modifier === 'pickup') {
|
|
this.remove_tile(mod);
|
|
}
|
|
|
|
// Drop any overflowed item
|
|
if (dropped_item) {
|
|
// TODO what if this fails??
|
|
this._place_dropped_item(dropped_item, cell, actor);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
// TODO what happens to the dropped item if the give fails somehow?
|
|
return false;
|
|
}
|
|
|
|
// Give an item to an actor, even if it's not supposed to have an inventory
|
|
give_actor(actor, name) {
|
|
if (! actor.type.is_actor)
|
|
return false;
|
|
|
|
let type = TILE_TYPES[name];
|
|
if (type.is_key) {
|
|
if (! actor.keyring) {
|
|
actor.keyring = {};
|
|
}
|
|
actor.keyring[name] = (actor.keyring[name] ?? 0) + 1;
|
|
this._push_pending_undo(() => actor.keyring[name] -= 1);
|
|
}
|
|
else {
|
|
// tool, presumably
|
|
if (! actor.toolbelt) {
|
|
actor.toolbelt = [];
|
|
}
|
|
|
|
if (this.stored_level.use_cc1_boots) {
|
|
// CC1's boot inventory is different; it has fixed slots, and duplicate items are
|
|
// silently ignored. CC2 items cannot be picked up.
|
|
let i = CC1_INVENTORY_ORDER.indexOf(name);
|
|
if (i < 0)
|
|
return false;
|
|
|
|
if (! actor.toolbelt[i]) {
|
|
this._push_pending_undo(() => actor.toolbelt[i] = null);
|
|
}
|
|
actor.toolbelt[i] = name;
|
|
}
|
|
else {
|
|
// "Normal" (CC2) inventory mode.
|
|
// Nothing can hold more than four items, so try to drop one first. Note that
|
|
// normally, this should already have happened in attempt_take, so this should only
|
|
// come up when forcibly given an item via debug tools
|
|
if (actor.toolbelt.length >= 4) {
|
|
if (! this.drop_item(actor))
|
|
return false;
|
|
}
|
|
|
|
actor.toolbelt.push(name);
|
|
this._push_pending_undo(() => actor.toolbelt.pop());
|
|
}
|
|
|
|
// FIXME hardcodey, but, this doesn't seem to fit anywhere else
|
|
if (name === 'cleats' && actor.slide_mode === 'ice') {
|
|
this.make_slide(actor, null);
|
|
}
|
|
else if (name === 'suction_boots' && actor.slide_mode === 'force') {
|
|
this.make_slide(actor, null);
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
take_key_from_actor(actor, name, ignore_infinity = false) {
|
|
if (actor.keyring && (actor.keyring[name] ?? 0) > 0) {
|
|
if (!ignore_infinity && actor.type.infinite_items && actor.type.infinite_items[name]) {
|
|
// Some items can't be taken away normally, by which I mean, green or yellow keys
|
|
return true;
|
|
}
|
|
|
|
this._push_pending_undo(() => actor.keyring[name] += 1);
|
|
actor.keyring[name] -= 1;
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Note that this doesn't support CC1 mode, but only CC2 and LL tools are individually taken
|
|
take_tool_from_actor(actor, name) {
|
|
if (actor.toolbelt) {
|
|
let index = actor.toolbelt.indexOf(name);
|
|
if (index >= 0) {
|
|
actor.toolbelt.splice(index, 1);
|
|
this._push_pending_undo(() => actor.toolbelt.splice(index, 0, name));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
take_all_keys_from_actor(actor) {
|
|
if (actor.keyring && Object.values(actor.keyring).some(n => n > 0)) {
|
|
let keyring = actor.keyring;
|
|
this._push_pending_undo(() => actor.keyring = keyring);
|
|
actor.keyring = {};
|
|
return true;
|
|
}
|
|
}
|
|
|
|
take_all_tools_from_actor(actor) {
|
|
if (actor.toolbelt && actor.toolbelt.length > 0) {
|
|
let toolbelt = actor.toolbelt;
|
|
this._push_pending_undo(() => actor.toolbelt = toolbelt);
|
|
actor.toolbelt = [];
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Mark an actor as sliding
|
|
make_slide(actor, mode) {
|
|
this._set_tile_prop(actor, 'slide_mode', mode);
|
|
}
|
|
|
|
// Change an actor's direction
|
|
set_actor_direction(actor, direction) {
|
|
this._set_tile_prop(actor, 'direction', direction);
|
|
}
|
|
}
|