This simplifies the renderer by having movement cooldowns only work one way, and thus removes the jank from Steam rendering. This commit also applies cooldowns for animations at decision time, as Lynx does, which eliminates a weird special case from their spawning. Also, Lexy mode now explicitly does not allow an actor to get cooled twice in one tic. However, this change does make clone machines no longer be aligned with the thing that pressed the button to clone them, which is unfortunate.
2517 lines
101 KiB
JavaScript
2517 lines
101 KiB
JavaScript
import * as algorithms from './algorithms.js';
|
|
import { DIRECTIONS, DIRECTION_ORDER, LAYERS, 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);
|
|
}
|
|
|
|
movement_progress(tic_offset, interpolate_backwards_by = 3) {
|
|
// FIXME this will need altering if 60fps actually updates at 60fps
|
|
return ((this.movement_speed - this.movement_cooldown - interpolate_backwards_by) + tic_offset * 3) / this.movement_speed;
|
|
}
|
|
|
|
// Gives the effective position of an actor in motion, given smooth scrolling
|
|
visual_position(tic_offset = 0, interpolate_backwards_by = 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_progress(tic_offset, interpolate_backwards_by);
|
|
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) {
|
|
let item_mod = this.cell.get_item_mod();
|
|
if (item_mod && item_mod.type.item_modifier)
|
|
return false;
|
|
|
|
let actor = this.cell.get_actor();
|
|
if (actor && actor.type.is_real_player)
|
|
return false;
|
|
}
|
|
|
|
if (this.type.blocks_collision & other.type.collision_mask)
|
|
return true;
|
|
|
|
// FIXME bowling ball isn't affected by helmet? also not sure bowling ball is stopped by
|
|
// helmet?
|
|
if (this.has_item('helmet') || (this.type.is_actor && ! this.type.ttl && other.has_item('helmet')))
|
|
return true;
|
|
|
|
// Blocks being pulled are blocked by their pullers (which are, presumably, the only things
|
|
// they can be moving towards)
|
|
// FIXME something about this broke pulling blocks through teleporters; see #99 Delirium
|
|
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)
|
|
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
|
|
// XXX this expects to take a level but it only matters with push_mode === 'push'
|
|
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(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;
|
|
}
|
|
|
|
// FIXME honestly no longer sure why these two are on Cell, or even separate really
|
|
try_leaving(actor, direction, level, 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 = this[LAYERS.terrain];
|
|
let thin_walls = this[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, 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, level, 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.
|
|
// - '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 = [];
|
|
// 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.
|
|
// - A bowling ball MUST NOT destroy an actor on the other side of a thin wall, or on top of
|
|
// a regular wall.
|
|
// - A fireball MUST melt an ice block AND ALSO still be deflected by it, even if the ice
|
|
// block is on top of an item (which blocks the fireball), but NOT one on the other side
|
|
// of a thin wall.
|
|
// - A rover MUST NOT bump walls underneath a canopy (which blocks it).
|
|
// It seems the order is thus: canopy + thin wall; terrain; actor; item. Which is the usual
|
|
// ordering from the top down, except that terrain is checked before actors. Really, the
|
|
// ordering is from "outermost" to "innermost", which makes physical sense.
|
|
for (let layer of [
|
|
LAYERS.canopy, LAYERS.thin_wall, LAYERS.terrain, LAYERS.swivel,
|
|
LAYERS.actor, LAYERS.item_mod, LAYERS.item])
|
|
{
|
|
let tile = this[layer];
|
|
if (! tile)
|
|
continue;
|
|
|
|
// TODO check ignores here?
|
|
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)) {
|
|
// 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, level, direction, tile);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
// Collect pushables for later, so we don't inadvertently push through a wall
|
|
pushable_tiles.push(tile);
|
|
}
|
|
|
|
// 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 (actor === level.player) {
|
|
level.sfx.play_once('push');
|
|
}
|
|
}
|
|
else {
|
|
// 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 && tile.blocks(actor, direction, level)))
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// 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;
|
|
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;
|
|
|
|
// 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) {
|
|
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_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 (tile && 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 (tile && 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];
|
|
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: 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) {
|
|
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));
|
|
|
|
// 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();
|
|
}
|
|
|
|
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 && 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', '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();
|
|
}
|
|
}
|
|
|
|
// FIXME merge this a bit more with the lynx loop, which should be more in finish_tic anyway
|
|
// FIXME fix turn-based mode
|
|
// FIXME you are now not synched with something coming out of a trap or cloner, but i don't know
|
|
// how to fix that with this loop
|
|
// Finish a tic, i.e., apply input just before the player can make a decision and then do it
|
|
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);
|
|
}
|
|
|
|
// 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.)
|
|
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();
|
|
|
|
this._do_cleanup_phase();
|
|
}
|
|
|
|
// Lexy-style loop, similar to Lynx but with some things split out into separate phases
|
|
_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();
|
|
}
|
|
|
|
// Lynx-style loop: everyone decides, then everyone moves/cools.
|
|
_begin_tic_lynx() {
|
|
// FIXME this should have three wire passes too, chief
|
|
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.type.ttl) {
|
|
// Animations, bizarrely, do their cooldown at decision time, so they're removed
|
|
// early on the tic that they expire
|
|
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3);
|
|
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 (! 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);
|
|
if (actor.type.ttl)
|
|
continue;
|
|
|
|
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.last_extra_cooldown_tic === this.tic_counter)
|
|
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);
|
|
}
|
|
// 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_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();
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
// 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;
|
|
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 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 (terrain.type.traps && terrain.type.traps(terrain, 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);
|
|
if (! dest_cell) {
|
|
if (push_mode === 'push') {
|
|
if (actor.type.on_blocked) {
|
|
actor.type.on_blocked(actor, this, direction, null);
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
let success = (
|
|
orig_cell.try_leaving(actor, direction, this, push_mode) &&
|
|
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'))
|
|
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)) {
|
|
this._do_extra_cooldown(actor);
|
|
// 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
|
|
// TODO now that i have steam-strict mode this is largely pointless, just do what seems
|
|
// correct
|
|
// FIXME remove this once i'm sure that it doesn't break cloners OR that cc1 tail-bite
|
|
// trap
|
|
//actor.cooldown_delay_hack = 1;
|
|
return true;
|
|
}
|
|
else {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
_do_extra_cooldown(actor) {
|
|
this._do_actor_cooldown(actor, this.compat.emulate_60fps ? 1 : 3);
|
|
// FIXME not for lynx i /think/?
|
|
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, speed) {
|
|
if (actor.cell === goal_cell)
|
|
return;
|
|
|
|
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);
|
|
|
|
// 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;
|
|
|
|
// 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, actor);
|
|
}
|
|
else if (actor.type.is_monster && tile.type.is_real_player) {
|
|
this.fail(actor.type.name, tile);
|
|
}
|
|
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', tile);
|
|
}
|
|
|
|
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 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
|
|
// 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.is_item &&
|
|
// FIXME implement item priority i'm begging you
|
|
((actor.type.has_inventory && ! (tile.type.name === 'key_red' && ! actor.type.is_player)) ||
|
|
(cell.get_item_mod() && cell.get_item_mod().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);
|
|
}
|
|
}
|
|
|
|
// 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 === '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;
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 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 && 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');
|
|
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);
|
|
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) {
|
|
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, so there's nothing we can do but destroy it
|
|
// TODO maybe blow it up with a nonblocking vfx? in cc2 it just vanishes
|
|
this.remove_tile(tile);
|
|
}
|
|
else {
|
|
this.add_actor(tile);
|
|
}
|
|
this.add_tile(dropping_actor, cell);
|
|
}
|
|
else {
|
|
this.add_tile(tile, cell);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
if (tile.type.on_tic) {
|
|
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);
|
|
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;
|
|
}
|
|
}
|
|
|
|
// 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() {
|
|
this.sfx.play_once('get-chip');
|
|
if (this.chips_remaining > 0) {
|
|
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, player = null) {
|
|
if (this.state !== 'playing')
|
|
return;
|
|
|
|
if (reason === 'time') {
|
|
this.sfx.play_once('timeup');
|
|
}
|
|
else {
|
|
this.sfx.play_once('lose');
|
|
}
|
|
|
|
if (player === null) {
|
|
player = this.player;
|
|
}
|
|
|
|
this._push_pending_undo(() => {
|
|
this.fail_reason = null;
|
|
player.fail_reason = null;
|
|
});
|
|
this.state = 'failure';
|
|
this.fail_reason = reason;
|
|
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;
|
|
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) {
|
|
this.actors.push(actor);
|
|
this._push_pending_undo(() => this.actors.pop());
|
|
}
|
|
|
|
spawn_animation(cell, name) {
|
|
let type = TILE_TYPES[name];
|
|
// Spawned VFX 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);
|
|
// 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);
|
|
this._do_extra_cooldown(tile);
|
|
cell._add(tile);
|
|
this.actors.push(tile);
|
|
this._push_pending_undo(() => {
|
|
this.actors.pop();
|
|
cell._remove(tile);
|
|
});
|
|
}
|
|
|
|
transmute_tile(tile, name) {
|
|
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._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);
|
|
this._do_extra_cooldown(tile);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
|
|
// 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 && 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) {
|
|
// 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 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) {
|
|
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);
|
|
}
|
|
}
|