This fixes an obscure bug where you could redirect a block that was in the process of sliding off of ice onto floor, because its slide mode had not yet been cleared.
2314 lines
91 KiB
JavaScript
2314 lines
91 KiB
JavaScript
import * as algorithms from './algorithms.js';
|
|
import { DIRECTIONS, DIRECTION_ORDER, INPUT_BITS, 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;
|
|
}
|
|
|
|
if (type.has_inventory) {
|
|
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);
|
|
}
|
|
|
|
// Gives the effective position of an actor in motion, given smooth scrolling
|
|
visual_position(tic_offset = 0) {
|
|
let x = this.cell.x;
|
|
let y = this.cell.y;
|
|
if (! this.previous_cell || this.movement_speed === null) {
|
|
return [x, y];
|
|
}
|
|
else {
|
|
// For a movement speed of N, the cooldown is set to N during the tic an actor starts
|
|
// moving, and we interpolate it from there to N - 1 over the course of the duration
|
|
let p = ((this.movement_speed - this.movement_cooldown) + tic_offset * 3) / this.movement_speed;
|
|
return [
|
|
(1 - p) * this.previous_cell.x + p * x,
|
|
(1 - p) * this.previous_cell.y + p * 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) {
|
|
// Extremely awkward special case: items don't block monsters if the cell also contains an
|
|
// item modifier (i.e. "no" sign) or a real player
|
|
// TODO would love to get this outta here
|
|
if (this.type.is_item &&
|
|
this.cell.some(tile => tile.type.item_modifier || tile.type.is_real_player))
|
|
return false;
|
|
|
|
if (this.type.blocks_collision & other.type.collision_mask)
|
|
return true;
|
|
|
|
// FIXME bowling ball isn't affected by helmet? also not sure bowling ball is stopped by
|
|
// helmet?
|
|
if (this.has_item('helmet') || (this.type.is_actor && ! this.type.ttl && other.has_item('helmet')))
|
|
return true;
|
|
|
|
// 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)
|
|
return this.type.blocks(this, level, other, direction);
|
|
|
|
return false;
|
|
}
|
|
|
|
ignores(name) {
|
|
if (this.type.ignores && this.type.ignores.has(name))
|
|
return true;
|
|
|
|
if (this.toolbelt) {
|
|
for (let item of this.toolbelt) {
|
|
let item_type = TILE_TYPES[item];
|
|
if (item_type.item_ignores && item_type.item_ignores.has(name))
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
can_push(tile, direction) {
|
|
// 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 tile.cell.try_leaving(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();
|
|
this.x = x;
|
|
this.y = y;
|
|
}
|
|
|
|
_add(tile, index = null) {
|
|
if (index === null) {
|
|
this.push(tile);
|
|
}
|
|
else {
|
|
this.splice(index, 0, 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 = this.indexOf(tile);
|
|
if (index < 0)
|
|
throw new Error("Asked to remove tile that doesn't seem to exist");
|
|
|
|
this.splice(index, 1);
|
|
tile.cell = null;
|
|
return index;
|
|
}
|
|
|
|
get_wired_tile() {
|
|
let ret = null;
|
|
for (let tile of this) {
|
|
if ((tile.wire_directions || tile.wire_tunnel_directions) && ! tile.movement_cooldown) {
|
|
ret = tile;
|
|
// Don't break; we want the topmost tile!
|
|
}
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
get_terrain() {
|
|
for (let tile of this) {
|
|
if (tile.type.draw_layer === 0)
|
|
return tile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
get_actor() {
|
|
for (let tile of this) {
|
|
if (tile.type.is_actor)
|
|
return tile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
get_item() {
|
|
for (let tile of this) {
|
|
if (tile.type.is_item)
|
|
return tile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
get_item_mod() {
|
|
for (let tile of this) {
|
|
if (tile.type.item_modifier)
|
|
return tile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
has(name) {
|
|
return this.some(tile => tile.type.name === name);
|
|
}
|
|
|
|
try_leaving(actor, direction) {
|
|
for (let tile of this) {
|
|
if (tile === actor)
|
|
continue;
|
|
|
|
if (tile.type.traps && tile.type.traps(tile, actor))
|
|
return false;
|
|
|
|
if (tile.type.blocks_leaving && tile.type.blocks_leaving(tile, actor, direction))
|
|
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.
|
|
// - 'push': Fire bump triggers. Attempt to move pushable objects out of the way immediately.
|
|
try_entering(actor, direction, level, push_mode = null) {
|
|
let pushable_tiles = [];
|
|
let blocked = false;
|
|
// (Note that here, and anywhere else that has any chance of altering the cell's contents,
|
|
// we iterate over a copy of the cell to insulate ourselves from tiles appearing or
|
|
// disappearing mid-iteration.)
|
|
for (let tile of Array.from(this).reverse()) {
|
|
// TODO check ignores here?
|
|
// Note that if they can't enter this cell because of a thin wall, then they can't bump
|
|
// any of our other tiles either. (This is my best guess at the actual behavior, seeing
|
|
// as walls also block everything but players can obviously bump /those/.)
|
|
if (! blocked) {
|
|
if (actor.type.on_bump) {
|
|
actor.type.on_bump(actor, level, tile, direction);
|
|
}
|
|
if (tile.type.on_bumped) {
|
|
tile.type.on_bumped(tile, level, actor);
|
|
}
|
|
}
|
|
|
|
if (! tile.blocks(actor, direction, level))
|
|
continue;
|
|
|
|
if (push_mode === null)
|
|
return false;
|
|
|
|
if (! actor.can_push(tile, direction)) {
|
|
if (push_mode === 'push') {
|
|
// Track this instead of returning immediately, because 'push' mode also bumps
|
|
// every tile in the cell
|
|
blocked = true;
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Collect pushables for later, so we don't inadvertently push through a wall
|
|
pushable_tiles.push(tile);
|
|
}
|
|
|
|
if (blocked)
|
|
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') {
|
|
// 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 ||
|
|
! level.check_movement(tile, tile.cell, direction, push_mode))
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
else if (push_mode === 'push') {
|
|
if (actor === level.player) {
|
|
level._set_tile_prop(actor, 'is_pushing', true);
|
|
}
|
|
if (! level.attempt_out_of_turn_step(tile, direction)) {
|
|
// If the push failed and the obstacle is in the middle of a slide,
|
|
// remember this as the next move it'll make.
|
|
if (tile.slide_mode !== null && tile.movement_cooldown > 0) {
|
|
level._set_tile_prop(tile, 'pending_push', direction);
|
|
}
|
|
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
|
|
// TODO unclear if this is the right way to emulate spring mining, but without the check
|
|
// for a player, it happens /too/ often; try allowing for ann actors and running the 163
|
|
// BLOX replay, and right at the end ice blocks spring mine each other. also, the wiki
|
|
// suggests something about another actor moving away at the same time?
|
|
if (! (level.compat.emulate_spring_mining && actor.type.is_real_player) &&
|
|
push_mode === 'push' && this.some(tile => tile.blocks(actor, direction, level)))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// Special railroad ability: change the direction we attempt to leave
|
|
redirect_exit(actor, direction) {
|
|
for (let tile of this) {
|
|
if (tile.type.redirect_exit) {
|
|
return tile.type.redirect_exit(tile, 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;
|
|
export class Level extends LevelInterface {
|
|
constructor(stored_level, compat = {}) {
|
|
super();
|
|
this.stored_level = stored_level;
|
|
this.restart(compat);
|
|
}
|
|
|
|
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;
|
|
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 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;
|
|
|
|
this.hint_shown = null;
|
|
// 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;
|
|
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;
|
|
// Speedup for flame jets, which aren't actors but do a thing every tic
|
|
// TODO this won't notice if a new tile with an on_tic is created, but that's impossible
|
|
// atm... or, at least, it's hacked to still work with flame_jet_off
|
|
this.static_on_tic_tiles = [];
|
|
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) {
|
|
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_actor) {
|
|
this.actors.push(tile);
|
|
}
|
|
else if (tile.type.on_tic) {
|
|
this.static_on_tic_tiles.push(tile);
|
|
}
|
|
cell._add(tile);
|
|
|
|
if (tile.type.connects_to) {
|
|
connectables.push(tile);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// TODO complain if no player
|
|
|
|
// Connect buttons and teleporters
|
|
let num_cells = this.width * this.height;
|
|
for (let connectable of connectables) {
|
|
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
|
|
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') {
|
|
target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null;
|
|
}
|
|
else if (connectable.type.name === 'button_red') {
|
|
target_cell_n = this.stored_level.custom_cloner_wiring[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 (goals === tile.type.name) {
|
|
connectable.connection = tile;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// Orange buttons do a really weird diamond search
|
|
if (connectable.type.connect_order === 'diamond') {
|
|
for (let cell of this.iter_cells_in_diamond(connectable.cell)) {
|
|
let target = null;
|
|
for (let tile of cell) {
|
|
if (goals.has(tile.type.name)) {
|
|
target = tile;
|
|
break;
|
|
}
|
|
}
|
|
if (target !== null) {
|
|
connectable.connection = target;
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
|
|
// Build circuits out of connected wires
|
|
// TODO document this idea
|
|
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
|
|
let terrain = cell.get_terrain();
|
|
if (! terrain) // ?!
|
|
continue;
|
|
|
|
if (terrain.type.is_power_source) {
|
|
this.power_sources.push(terrain);
|
|
}
|
|
|
|
let wire_directions = terrain.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];
|
|
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: false,
|
|
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.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.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));
|
|
|
|
// 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.type.on_ready) {
|
|
tile.type.on_ready(tile, this);
|
|
}
|
|
if (cell === this.player.cell && tile.type.is_hint) {
|
|
this.hint_shown = tile.hint_text ?? this.stored_level.hint;
|
|
}
|
|
}
|
|
}
|
|
// Erase undo, in case any on_ready added to it (we don't want to undo initialization!)
|
|
this.pending_undo = this.create_undo_entry();
|
|
}
|
|
|
|
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.
|
|
// Note that this only makes sense in the middle of a tic; at the beginning of one, the
|
|
// player's movement cooldown may very well be 1, but it'll be decremented before they
|
|
// attempt to move
|
|
return this.player.movement_cooldown === 0 && (this.player.slide_mode === null || (
|
|
this.player.slide_mode === 'force' && this.player.last_move_was_force));
|
|
}
|
|
|
|
// Lynx PRNG, used unchanged in CC2
|
|
prng() {
|
|
let n = (this._rng1 >> 2) - this._rng1;
|
|
if (!(this._rng1 & 0x02)) --n;
|
|
this._rng1 = (this._rng1 >> 1) | (this._rng2 & 0x80);
|
|
this._rng2 = (this._rng2 << 1) | (n & 0x01);
|
|
let ret = (this._rng1 ^ this._rng2) & 0xff;
|
|
return ret;
|
|
}
|
|
|
|
// 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 (but /after/ returning)
|
|
//this._blob_modifier = (this._blob_modifier + 1) % 4;
|
|
mod = (mod + 1) % 4;
|
|
this._blob_modifier = mod;
|
|
}
|
|
else {
|
|
// Other modes do this curious operation
|
|
mod *= 2;
|
|
if (mod < 255) {
|
|
mod ^= 0x1d;
|
|
}
|
|
mod &= 0xff;
|
|
this._blob_modifier = mod;
|
|
}
|
|
|
|
return mod;
|
|
}
|
|
|
|
// 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(`Level.advance_tic() called when state is ${this.state}`);
|
|
return;
|
|
}
|
|
|
|
this.begin_tic(p1_input);
|
|
this.finish_tic(p1_input);
|
|
}
|
|
|
|
// FIXME a whole bunch of these comments are gonna be wrong or confusing now
|
|
begin_tic(p1_input) {
|
|
// 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.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', 'force_floor_direction',
|
|
'tic_counter', 'time_remaining', 'timer_paused',
|
|
'chips_remaining', 'bonus_points', 'hint_shown', 'state',
|
|
'remaining_players', 'player',
|
|
]) {
|
|
this.pending_undo.level_props[key] = this[key];
|
|
}
|
|
}
|
|
this.p1_input = p1_input;
|
|
this.p1_released |= ~p1_input; // Action keys released since we last checked them
|
|
this.swap_player1 = false;
|
|
|
|
this.sfx.set_player_position(this.player.cell);
|
|
|
|
if (this.compat.use_lynx_loop) {
|
|
if (this.compat.emulate_60fps) {
|
|
this._begin_tic_lynx60();
|
|
}
|
|
else {
|
|
this._begin_tic_lynx();
|
|
}
|
|
}
|
|
else {
|
|
this._begin_tic_lexy();
|
|
}
|
|
}
|
|
|
|
// Only the Lexy-style loop has a notion of "finishing" a tic, since (unlike the Lynx loop) the
|
|
// decision phase happens in the /middle/
|
|
finish_tic(p1_input) {
|
|
this.p1_input = p1_input;
|
|
this.p1_released |= ~p1_input; // Action keys released since we last checked them
|
|
|
|
if (this.compat.use_lynx_loop) {
|
|
if (this.compat.emulate_60fps) {
|
|
this._finish_tic_lynx60();
|
|
}
|
|
return;
|
|
}
|
|
|
|
this._do_decision_phase();
|
|
|
|
// Lexy's separate movement loop
|
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
|
let actor = this.actors[i];
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
this._do_actor_movement(actor, actor.decision);
|
|
}
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Lexy-style loop, the one I stumbled upon accidentally. Here, cooldowns happen /first/ as
|
|
// their own phase, then decisions are made, then movement happens. This approach has several
|
|
// advantages: there's no cooldown on the same tic that movement begins, which lets the renderer
|
|
// interpolate between tics more easily; there's no jitter when pushing a block, as is seen in
|
|
// CC2; and generally more things happen in parallel, which improves the illusion that all the
|
|
// game objects are acting simultaneously.
|
|
_begin_tic_lexy() {
|
|
// CC2 wiring runs every frame, not every tic, so we need to do it three times, but dealing
|
|
// with it is delicate. We want the result of a button press to draw, but not last longer
|
|
// than intended, so we only want one update between the end of the cooldown pass and the
|
|
// end of the tic. That means the other two have to go here. When a level starts, there
|
|
// are only two wiring updates before everything gets its first chance to move, so we skip
|
|
// the very first one here.
|
|
if (this.tic_counter !== 0) {
|
|
this._do_wire_phase();
|
|
}
|
|
this._do_wire_phase();
|
|
|
|
// 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;
|
|
|
|
this._do_actor_cooldown(actor, 3);
|
|
}
|
|
|
|
// Mini extra pass: deal with teleporting separately. Otherwise, actors may have been in
|
|
// the way of the teleporter but finished moving away during the above loop; this is
|
|
// particularly bad when it happens with a block you're pushing. (CC2 doesn't need to do
|
|
// this because blocks you're pushing are always a frame ahead of you anyway.)
|
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
|
let actor = this.actors[i];
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
if (actor.just_stepped_on_teleporter) {
|
|
this.attempt_teleport(actor);
|
|
}
|
|
}
|
|
|
|
this._do_wire_phase();
|
|
// TODO should this also happen three times?
|
|
this._do_static_phase();
|
|
}
|
|
|
|
// Lynx-style loop: everyone decides, then everyone moves/cools.
|
|
_begin_tic_lynx() {
|
|
this._do_decision_phase();
|
|
this._do_combined_action_phase(3);
|
|
this._do_wire_phase();
|
|
this._do_static_phase();
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Same as above, but split up to run at 60fps, where only every third frame allows for
|
|
// decisions. This is how CC2 works.
|
|
_begin_tic_lynx60() {
|
|
this._do_decision_phase(true);
|
|
this._do_combined_action_phase(1, true);
|
|
this._do_wire_phase();
|
|
this._do_static_phase();
|
|
|
|
this._do_decision_phase(true);
|
|
this._do_combined_action_phase(1, true);
|
|
this._do_wire_phase();
|
|
this._do_static_phase();
|
|
}
|
|
// This is in the "finish" part to preserve the property turn-based mode expects, where "finish"
|
|
// picks up right when the player could provide input
|
|
_finish_tic_lynx60() {
|
|
this._do_decision_phase();
|
|
this._do_combined_action_phase(1);
|
|
this._do_wire_phase();
|
|
this._do_static_phase();
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Decision phase: all actors decide on their movement "simultaneously"
|
|
_do_decision_phase(forced_only = false) {
|
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
|
let actor = this.actors[i];
|
|
|
|
// Clear any old decisions ASAP. Note that this prop is only used internally within a
|
|
// single tic, so it doesn't need to be undoable
|
|
actor.decision = null;
|
|
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
if (actor.movement_cooldown > 0)
|
|
continue;
|
|
|
|
if (! forced_only && actor.type.on_tic) {
|
|
actor.type.on_tic(actor, this);
|
|
if (! actor.cell)
|
|
continue;
|
|
}
|
|
|
|
if (actor === this.player) {
|
|
this.make_player_decision(actor, this.p1_input, forced_only);
|
|
}
|
|
else {
|
|
this.make_actor_decision(actor, forced_only);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Lynx's combined action phase: each actor attempts to move, then cools down, in order
|
|
_do_combined_action_phase(cooldown, forced_only = false) {
|
|
for (let i = this.actors.length - 1; i >= 0; i--) {
|
|
let actor = this.actors[i];
|
|
if (! actor.cell)
|
|
continue;
|
|
|
|
this._do_actor_movement(actor, actor.decision);
|
|
this._do_actor_cooldown(actor, cooldown);
|
|
if (actor.just_stepped_on_teleporter) {
|
|
this.attempt_teleport(actor);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Have an actor attempt to move
|
|
_do_actor_movement(actor, direction) {
|
|
// Check this again, since an earlier pass may have caused us to start moving
|
|
if (actor.movement_cooldown > 0)
|
|
return;
|
|
|
|
if (! direction)
|
|
return true;
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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 an RFF rolls a new direction and tries again
|
|
// 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.name === 'force_floor_all' &&
|
|
actor.slide_mode && ! actor.ignores(terrain.type.name))))
|
|
{
|
|
// Turn the actor around (so ice corners bonk correctly), pretend they stepped on
|
|
// the tile again (so RFFs roll again), and try moving again
|
|
this.set_actor_direction(actor, DIRECTIONS[direction].opposite);
|
|
// 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);
|
|
}
|
|
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) {
|
|
this.sfx.play_once('blocked');
|
|
this._set_tile_prop(actor, 'is_blocked', true);
|
|
}
|
|
|
|
return success;
|
|
}
|
|
|
|
_do_actor_cooldown(actor, cooldown = 3) {
|
|
if (actor.movement_cooldown <= 0)
|
|
return;
|
|
|
|
if (actor.cooldown_delay_hack) {
|
|
// See the extensive comment in attempt_out_of_turn_step
|
|
actor.cooldown_delay_hack += 1;
|
|
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);
|
|
}
|
|
// Erase any trace of being in mid-movement, however:
|
|
// - This has to happen after stepping on cells, because some effects care about
|
|
// the cell we're arriving from
|
|
// - Don't do it if stepping on the cell caused us to move again
|
|
if (actor.movement_cooldown <= 0) {
|
|
this._set_tile_prop(actor, 'previous_cell', null);
|
|
this._set_tile_prop(actor, 'movement_speed', null);
|
|
if (actor.is_pulled) {
|
|
this._set_tile_prop(actor, 'is_pulled', false);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
_do_cleanup_phase() {
|
|
// Strip out any destroyed actors from the acting order
|
|
// FIXME this is O(n), where n is /usually/ small, but i still don't love it. not strictly
|
|
// necessary, either; maybe only do it every few tics?
|
|
let p = 0;
|
|
for (let i = 0, l = this.actors.length; i < l; i++) {
|
|
let actor = this.actors[i];
|
|
// While we're here, delete this guy
|
|
delete actor.cooldown_delay_hack;
|
|
|
|
if (actor.cell) {
|
|
if (p !== i) {
|
|
this.actors[p] = actor;
|
|
}
|
|
p++;
|
|
}
|
|
else {
|
|
let local_p = p;
|
|
this._push_pending_undo(() => this.actors.splice(local_p, 0, actor));
|
|
}
|
|
}
|
|
this.actors.length = p;
|
|
|
|
// 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) {
|
|
// 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;
|
|
|
|
// 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;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (this.remaining_players <= 0) {
|
|
this.win();
|
|
}
|
|
|
|
// 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();
|
|
}
|
|
|
|
_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);
|
|
}
|
|
|
|
// If the game has already been won (or lost), don't bother with a move; it'll misalign the
|
|
// player from their actual position and not accomplish anything gameplay-wise.
|
|
// (Note this is only necessary because our update order is inverted from CC2, and because
|
|
// we don't erase the last player when they exit.)
|
|
// TODO might not be good enough if something else tries to step on us later this tic!
|
|
if (this.state !== 'playing' || this.remaining_players <= 0)
|
|
return null;
|
|
|
|
// 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;
|
|
this.p1_released = 0xff;
|
|
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) {
|
|
this.drop_item(this.player);
|
|
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 open;
|
|
if (dir2 === null) {
|
|
// Only one direction is held, but for consistency, "check" it anyway
|
|
open = try_direction(dir1, 'push');
|
|
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');
|
|
let other_open = try_direction(other_direction, 'push');
|
|
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');
|
|
let open2 = try_direction(dir2, 'push');
|
|
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, the
|
|
// force floor takes priority (and we've already bumped the wall(s))
|
|
if (actor.slide_mode === 'force' && ! open) {
|
|
this._set_tile_prop(actor, 'last_move_was_force', true);
|
|
actor.decision = actor.direction;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
make_actor_decision(actor, forced_only = false) {
|
|
// Compat flag for blue tanks
|
|
if (this.compat.sliding_tanks_ignore_button &&
|
|
actor.slide_mode && actor.pending_reverse)
|
|
{
|
|
this._set_tile_prop(actor, 'pending_reverse', false);
|
|
}
|
|
|
|
if (actor.pending_push) {
|
|
// Blocks that were pushed while sliding will move in the push direction as soon as
|
|
// they stop sliding, regardless of what they landed on. Also used for hooking.
|
|
// This isn't cleared until the block makes another 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 (actor.cell.some(tile => tile.type.traps && tile.type.traps(tile, 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);
|
|
}
|
|
|
|
// Check which of those directions we *can*, probably, move in
|
|
if (! direction_preference)
|
|
return;
|
|
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, 'bump')) {
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
|
|
check_movement(actor, orig_cell, direction, push_mode) {
|
|
let dest_cell = this.get_neighboring_cell(orig_cell, direction);
|
|
let success = (dest_cell &&
|
|
orig_cell.try_leaving(actor, direction) &&
|
|
dest_cell.try_entering(actor, direction, this, push_mode));
|
|
|
|
// If we have the hook, pull anything behind us, now that we're out of the way.
|
|
// 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 actually swiveled at decision time).
|
|
// FIXME so, we shouldn't be able to pull a block through a swivel, but the swivel doesn't
|
|
// turn until on_depart, which is after this because we have to know we can actually move
|
|
// first. but also, hooking can stop us from moving, but it /does/ still allow us to push.
|
|
// also this all seems to apply exactly the same to monsters, except of course they can't
|
|
// hook slap. so where the hell does this actually go?
|
|
if (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();
|
|
// FIXME starting to think fx should not count as actors
|
|
if (behind_actor && ! behind_actor.type.ttl &&
|
|
// 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?
|
|
(! behind_actor.type.allows_push || behind_actor.type.allows_push(behind_actor, direction)))
|
|
{
|
|
if (behind_actor.movement_cooldown) {
|
|
// FIXME this sucks actually, make it not default behavior
|
|
return false;
|
|
}
|
|
else if (behind_actor.type.is_block && push_mode === 'push') {
|
|
this._set_tile_prop(behind_actor, 'is_pulled', true);
|
|
// FIXME i am pretty sure lexy benefits immensely from doing an immediate
|
|
// move, which also makes it match pushing, but that only works if this
|
|
// happens at movement time, which prevents hook slapping, which is nonsense
|
|
// anyway
|
|
//this.attempt_out_of_turn_step(behind_actor, direction);
|
|
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;
|
|
}
|
|
this.set_actor_direction(actor, 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 move = DIRECTIONS[direction].movement;
|
|
if (! this.check_movement(actor, actor.cell, direction, 'push')) {
|
|
if (actor.type.on_blocked) {
|
|
actor.type.on_blocked(actor, this, direction);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// We're clear! Compute our speed and move us
|
|
// FIXME this feels clunky
|
|
let goal_cell = this.get_neighboring_cell(actor.cell, direction);
|
|
let terrain = goal_cell.get_terrain();
|
|
if (actor.has_item('speed_boots')) {
|
|
speed /= 2;
|
|
}
|
|
else if (terrain && terrain.type.speed_factor && ! actor.ignores(terrain.type.name)) {
|
|
speed /= terrain.type.speed_factor;
|
|
}
|
|
|
|
let orig_cell = actor.cell;
|
|
this._set_tile_prop(actor, 'previous_cell', orig_cell);
|
|
this._set_tile_prop(actor, 'movement_cooldown', speed * 3);
|
|
this._set_tile_prop(actor, 'movement_speed', speed * 3);
|
|
this.move_to(actor, goal_cell, speed);
|
|
|
|
return true;
|
|
}
|
|
|
|
attempt_out_of_turn_step(actor, direction) {
|
|
if (this.compat.use_lynx_loop) {
|
|
let success = this._do_actor_movement(actor, direction);
|
|
if (success) {
|
|
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3);
|
|
}
|
|
return success;
|
|
}
|
|
|
|
if (this.attempt_step(actor, direction)) {
|
|
// Here's the problem.
|
|
// In CC2, cooldown is measured in frames, not tics, and it decrements every frame, not
|
|
// every tic. You usually don't notice because actors can only initiate moves every
|
|
// three frames (= 1 tic), so the vast majority of the game is tic-aligned.
|
|
// This is where it leaks. If actor X's cooldown causes them to press a button which
|
|
// then initiates an out-of-turn move (i.e. the forced move from a clone or trap
|
|
// ejection) for actor Y, and actor Y comes later in the actor order, then actor Y will
|
|
// decrement its cooldown during that same cooldown phase, putting it /between/ tics.
|
|
// If I copy this behavior and decrement by an entire tic, then actor Y will stay a full
|
|
// tic ahead indefinitely, whereas in CC2 it would only be a frame ahead and would
|
|
// eventually have to wait a frame before it could move again. If I ignore this
|
|
// behavior wholesale and don't let actor Y decrement at all, then a player being
|
|
// ejected from a trap could still have her tail bitten. So here's the compromise: I
|
|
// set this turn-local flag on actor Y; then if they do have a cooldown later, it's
|
|
// ignored BUT the flag is incremented; and then if the flag is 2, the actor is exempt
|
|
// from tail-biting. (I am reasonably confident that tail-biting is the only possible
|
|
// side effect here, since actor Y has left the cell even if they haven't visibly moved
|
|
// yet, and tail-biting is the sole effect that looks "back in time".)
|
|
// XXX that's still not perfect; if actor X is tic-misaligned, like if there's a chain
|
|
// of 3 or more actors cloning directly onto red buttons for other cloners, then this
|
|
// cannot possibly work
|
|
actor.cooldown_delay_hack = 1;
|
|
return true;
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 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, speed) {
|
|
if (actor.cell === goal_cell)
|
|
return;
|
|
|
|
let original_cell = actor.cell;
|
|
this.remove_tile(actor);
|
|
this.add_tile(actor, goal_cell);
|
|
|
|
// Announce we're leaving, for the handful of tiles that care about it
|
|
for (let tile of Array.from(original_cell)) {
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
|
|
if (tile.type.on_depart) {
|
|
tile.type.on_depart(tile, this, actor);
|
|
}
|
|
}
|
|
|
|
// Check for a couple effects that always apply immediately
|
|
if (actor === this.player) {
|
|
this.hint_shown = null;
|
|
}
|
|
for (let tile of goal_cell) {
|
|
// FIXME this could go in on_approach now
|
|
if (actor === this.player && tile.type.is_hint) {
|
|
this.hint_shown = tile.hint_text ?? this.stored_level.hint;
|
|
}
|
|
}
|
|
|
|
if (actor === this.player && goal_cell[0].type.name === 'floor') {
|
|
this.sfx.play_once('step-floor');
|
|
}
|
|
|
|
// 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 Array.from(actor.cell)) {
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
|
|
// Possibly kill a player
|
|
if (actor.has_item('helmet') || tile.has_item('helmet')) {
|
|
// Helmet disables this, do nothing
|
|
}
|
|
else if (actor.type.is_real_player && tile.type.is_monster) {
|
|
this.fail(tile.type.name);
|
|
}
|
|
else if (actor.type.is_monster && tile.type.is_real_player) {
|
|
this.fail(actor.type.name);
|
|
}
|
|
else if (actor.type.is_block && tile.type.is_real_player && ! actor.is_pulled) {
|
|
// Note that blocks squish players if they move for ANY reason, even if pushed by
|
|
// another player! The only exception is being pulled
|
|
this.fail('squished');
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// If we're a monster stepping on the player's tail, that also kills her immediately; the
|
|
// player and a monster must be strictly more than 4 tics apart
|
|
// FIXME this only works for the /current/ player but presumably applies to all of them,
|
|
// though i'm having trouble coming up with a test
|
|
// TODO the rules in lynx might be slightly different?
|
|
if (actor.type.is_monster && goal_cell === this.player.previous_cell &&
|
|
// Player has decided to leave their cell, but hasn't actually taken a step yet
|
|
this.player.movement_cooldown === this.player.movement_speed &&
|
|
! actor.has_item('helmet') && ! this.player.has_item('helmet') &&
|
|
// See the extensive comment in attempt_out_of_turn_step
|
|
this.player.cooldown_delay_hack !== 2)
|
|
{
|
|
this.fail(actor.type.name);
|
|
}
|
|
|
|
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) {
|
|
// Step on topmost things first -- notably, it's safe to step on water with flippers on top
|
|
for (let tile of Array.from(cell).reverse()) {
|
|
if (tile === actor)
|
|
continue;
|
|
if (actor.ignores(tile.type.name))
|
|
continue;
|
|
|
|
if (tile.type.is_item &&
|
|
// FIXME implement item priority i'm begging you
|
|
((actor.type.has_inventory && ! (tile.type.name === 'key_red' && ! actor.type.is_player)) ||
|
|
cell.some(t => t.type.item_modifier === 'pickup')) &&
|
|
this.attempt_take(actor, tile))
|
|
{
|
|
if (tile.type.is_key) {
|
|
this.sfx.play_once('get-key', cell);
|
|
}
|
|
else {
|
|
this.sfx.play_once('get-tool', cell);
|
|
}
|
|
}
|
|
else if (tile.type.teleport_dest_order) {
|
|
// This is used by an extra pass just after our caller, so it doesn't need to undo
|
|
actor.just_stepped_on_teleporter = tile;
|
|
}
|
|
else if (tile.type.on_arrive) {
|
|
tile.type.on_arrive(tile, this, actor);
|
|
}
|
|
}
|
|
}
|
|
|
|
attempt_teleport(actor) {
|
|
let teleporter = actor.just_stepped_on_teleporter;
|
|
delete actor.just_stepped_on_teleporter;
|
|
|
|
if (teleporter.type.name === 'teleport_red' && ! teleporter.type._is_active(teleporter, this)) {
|
|
// 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.)
|
|
this.make_slide(actor, 'teleport-forever');
|
|
// Also, there's no sound and whatnot, so everything else is skipped outright.
|
|
return;
|
|
}
|
|
|
|
let original_direction = actor.direction;
|
|
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.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 (actor.type.has_inventory && teleporter.type.name === 'teleport_yellow') {
|
|
// Super duper special yellow teleporter behavior: you pick it the fuck up
|
|
// FIXME not if there's only one in the level?
|
|
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');
|
|
this.spawn_animation(dest.cell, 'teleport_flash');
|
|
|
|
// Now physically move the actor, but their movement waits until next decision phase
|
|
this.remove_tile(actor);
|
|
this.add_tile(actor, dest.cell);
|
|
}
|
|
|
|
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, force = false) {
|
|
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;
|
|
|
|
if (actor.cell.get_item() && ! force)
|
|
return false;
|
|
|
|
// Drop the oldest item, i.e. the first one
|
|
let name = actor.toolbelt[0];
|
|
if (name === 'teleport_yellow') {
|
|
// We can only be dropped on regular floor
|
|
let terrain = actor.cell.get_terrain();
|
|
if (terrain.type.name !== 'floor')
|
|
return false;
|
|
|
|
this.transmute_tile(terrain, 'teleport_yellow');
|
|
}
|
|
else {
|
|
let type = TILE_TYPES[name];
|
|
if (type.on_drop) {
|
|
name = type.on_drop(this, actor);
|
|
if (name) {
|
|
type = TILE_TYPES[name];
|
|
}
|
|
}
|
|
let tile = new Tile(type);
|
|
this.add_tile(tile, actor.cell);
|
|
if (type.is_actor) {
|
|
this.add_actor(tile);
|
|
this.attempt_out_of_turn_step(tile, actor.direction);
|
|
}
|
|
}
|
|
|
|
actor.toolbelt.shift();
|
|
this._push_pending_undo(() => actor.toolbelt.unshift(name));
|
|
|
|
return true;
|
|
}
|
|
|
|
_do_wire_phase() {
|
|
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, 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')) {
|
|
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)
|
|
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;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Some non-actor tiles still want to act every tic. Note that this should happen AFTER wiring.
|
|
_do_static_phase() {
|
|
for (let tile of this.static_on_tic_tiles) {
|
|
tile.type.on_tic(tile, this);
|
|
}
|
|
}
|
|
|
|
// -------------------------------------------------------------------------
|
|
// Board inspection
|
|
|
|
get_neighboring_cell(cell, direction) {
|
|
let move = DIRECTIONS[direction].movement;
|
|
let goal_x = cell.x + move[0];
|
|
let goal_y = cell.y + move[1];
|
|
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);
|
|
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];
|
|
for (let tile of cell) {
|
|
if (tile.type.name === name) {
|
|
yield tile;
|
|
}
|
|
}
|
|
|
|
if (cell === start_cell)
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Iterates over the grid in a diamond pattern, spreading out from the given start cell (but not
|
|
// including it). Only used for connecting orange buttons.
|
|
*iter_cells_in_diamond(start_cell) {
|
|
let max_search_radius = Math.max(this.size_x, this.size_y) + 1;
|
|
for (let dist = 1; dist <= max_search_radius; dist++) {
|
|
// Start east and move counterclockwise
|
|
let sx = start_cell.x + dist;
|
|
let sy = start_cell.y;
|
|
for (let direction of [[-1, -1], [-1, 1], [1, 1], [1, -1]]) {
|
|
for (let i = 0; i < dist; i++) {
|
|
let cell = this.cell(sx, sy);
|
|
if (cell) {
|
|
yield cell;
|
|
}
|
|
sx += direction[0];
|
|
sy += direction[1];
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 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 handling
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 methods. 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) {
|
|
this.sfx.play_once('get-chip');
|
|
this.chips_remaining--;
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
fail(reason) {
|
|
if (this.state !== 'playing')
|
|
return;
|
|
|
|
if (reason === 'time') {
|
|
this.sfx.play_once('timeup');
|
|
}
|
|
else {
|
|
this.sfx.play_once('lose');
|
|
}
|
|
|
|
this._push_pending_undo(() => {
|
|
this.fail_reason = null;
|
|
this.player.fail_reason = null;
|
|
});
|
|
this.state = 'failure';
|
|
this.fail_reason = reason;
|
|
this.player.fail_reason = reason;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
let time = Math.ceil((this.time_remaining ?? 0) / 20);
|
|
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() {
|
|
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;
|
|
let index = cell._remove(tile);
|
|
this._push_pending_undo(() => cell._add(tile, index));
|
|
}
|
|
|
|
add_tile(tile, cell, index = null) {
|
|
cell._add(tile, index);
|
|
this._push_pending_undo(() => cell._remove(tile));
|
|
}
|
|
|
|
add_actor(actor) {
|
|
this.actors.push(actor);
|
|
this._push_pending_undo(() => this.actors.pop());
|
|
}
|
|
|
|
spawn_animation(cell, name) {
|
|
let type = TILE_TYPES[name];
|
|
let tile = new Tile(type);
|
|
// 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
|
|
this._set_tile_prop(tile, 'movement_speed', tile.type.ttl);
|
|
this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1);
|
|
cell._add(tile);
|
|
this.actors.push(tile);
|
|
this._push_pending_undo(() => {
|
|
this.actors.pop();
|
|
cell._remove(tile);
|
|
});
|
|
}
|
|
|
|
transmute_tile(tile, name) {
|
|
let current = tile.type.name;
|
|
this._push_pending_undo(() => tile.type = TILE_TYPES[current]);
|
|
tile.type = TILE_TYPES[name];
|
|
|
|
// For transmuting into an animation, set up the timer immediately
|
|
if (tile.type.ttl) {
|
|
if (! TILE_TYPES[current].is_actor) {
|
|
console.warn("Transmuting a non-actor into an animation!");
|
|
}
|
|
this._set_tile_prop(tile, 'previous_cell', null);
|
|
this._set_tile_prop(tile, 'movement_speed', tile.type.ttl);
|
|
this._set_tile_prop(tile, 'movement_cooldown', tile.type.ttl - 1);
|
|
}
|
|
}
|
|
|
|
// 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
|
|
attempt_take(actor, tile) {
|
|
let mod = tile.cell.get_item_mod();
|
|
if (mod && mod.type.item_modifier === 'ignore')
|
|
return false;
|
|
|
|
if (this.give_actor(actor, tile.type.name)) {
|
|
if (tile.type.draw_layer === 0) {
|
|
// 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);
|
|
}
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Give an item to an actor, even if it's not supposed to have an inventory
|
|
give_actor(actor, name) {
|
|
// TODO support use_cc1_boots here -- silently consume dupes, only do cc1 items
|
|
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 = [];
|
|
}
|
|
|
|
// Nothing can hold more than four items, so try to drop one first. Note that this may
|
|
// temporarily cause there to be two items in the cell if we're in the middle of picking
|
|
// one up, and it means we can't pick up a yellow teleport and swap out another for it
|
|
// FIXME two items at once is bad, please fix caller somehow
|
|
if (actor.toolbelt.length === 4) {
|
|
if (! this.drop_item(actor, true))
|
|
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) {
|
|
if (actor.keyring && (actor.keyring[name] ?? 0) > 0) {
|
|
if (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;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|