import { DIRECTIONS, TICS_PER_SECOND } from './defs.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) { return [x, y]; } else { let p = (this.animation_progress + tic_offset) / this.animation_speed; return [ (1 - p) * this.previous_cell.x + p * x, (1 - p) * this.previous_cell.y + p * y, ]; } } 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 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_player)) return false; if (this.type.blocks_collision & other.type.collision_mask) return true; if (this.type.thin_walls && this.type.thin_walls.has(DIRECTIONS[direction].opposite)) return true; if (this.type.blocks) return this.type.blocks(this, level, other); 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) { return ( this.type.pushes && this.type.pushes[tile.type.name] && (! tile.type.allows_push || tile.type.allows_push(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 ! tile.cell.blocks_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; 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) { ret = tile; // Don't break; we want the topmost tile! } } return ret; } 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; } blocks_leaving(actor, direction) { for (let tile of this) { if (tile === actor) continue; if (tile.type.traps && tile.type.traps(tile, actor)) return true; if (tile.type.blocks_leaving && tile.type.blocks_leaving(tile, actor, direction)) return true; } return false; } blocks_entering(actor, direction, level, ignore_pushables = false) { for (let tile of this) { if (tile.blocks(actor, direction, level) && ! (ignore_pushables && actor.can_push(tile, direction))) { return true; } } return false; } } Cell.prototype.prev_powered_edges = 0; Cell.prototype.powered_edges = 0; class GameEnded extends Error {} // 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 { constructor(stored_level, compat = {}) { this.stored_level = stored_level; this.width = stored_level.size_x; this.height = stored_level.size_y; this.size_x = stored_level.size_x; this.size_y = stored_level.size_y; 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.cells = []; this.player = null; this.actors = []; this.chips_remaining = this.stored_level.chips_required; this.bonus_points = 0; this.aid = 0; // Time 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(); let n = 0; let connectables = []; this.power_sources = []; // FIXME handle traps correctly: // - if an actor is in the cell, set the trap to open and unstick everything in it for (let y = 0; y < this.height; y++) { let row = []; this.cells.push(row); for (let x = 0; x < this.width; x++) { let cell = new Cell(x, y); row.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.specific_hint = template_tile.specific_hint ?? null; } if (tile.type.is_power_source) { this.power_sources.push(tile); } if (tile.type.is_player) { // TODO handle multiple players, also chip and melinda both // TODO complain if no player this.player = tile; } if (tile.type.is_actor) { this.actors.push(tile); } cell._add(tile); if (tile.type.connects_to) { connectables.push(tile); } } } } // 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.cells[ty][tx]) { 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; } } // Finally, let all tiles do any custom init behavior for (let row of this.cells) { for (let cell of row) { 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.specific_hint ?? 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. // For turn-based mode, this is split into two parts: advance_tic_finish_movement completes any // ongoing movement started in the previous tic, and advance_tic_act allows actors to make new // decisions. The player makes decisions between these two parts. advance_tic(p1_primary_direction, p1_secondary_direction, pass) { if (this.state !== 'playing') { console.warn(`Level.advance_tic() called when state is ${this.state}`); return; } // TODO rip out this try/catch, it's not how the game actually works try { if (pass == 1) { this.advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction); } else if (pass == 2) { this.advance_tic_act(p1_primary_direction, p1_secondary_direction); } else { console.warn(`What pass is this?`); } } catch (e) { if (e instanceof GameEnded) { // Do nothing, the game ended and we just wanted to skip the rest } else { throw e; } } // Commit the undo state at the end of each tic (pass 2) if (pass == 2) { this.commit(); } } advance_tic_finish_movement(p1_primary_direction, p1_secondary_direction) { // 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' ]) { this.pending_undo.level_props[key] = this[key]; } // Player's secondary direction is set immediately; it applies on arrival to cells even if // it wasn't held the last time the player started moving this._set_tile_prop(this.player, 'secondary_direction', p1_secondary_direction); // Used to check for a monster chomping the player's tail this.player_leaving_cell = this.player.cell; // Used for visual effect and updated later; don't need to be undoable // because they only apply while holding a key down anyway // TODO but maybe they should be undone anyway so rewind looks better this.player.is_blocked = false; this.sfx.set_player_position(this.player.cell); // First pass: tick cooldowns and animations; have actors arrive in their cells. We do the // arrival as its own mini pass, for one reason: if the player dies (which will end the game // immediately), we still want every time's animation to finish, or it'll look like some // objects move backwards when the death screen appears! let cell_steppers = []; // 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 earlier passes do things like advance // the RNG, so for replay compatibility they need 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; // 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; // Decrement the cooldown here, but don't check it quite yet, // because stepping on cells in the next block might reset it if (actor.movement_cooldown > 0) { this._set_tile_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1); } if (actor.animation_speed) { // Deal with movement animation this._set_tile_prop(actor, 'animation_progress', actor.animation_progress + 1); if (actor.animation_progress >= actor.animation_speed) { if (actor.type.ttl) { // This is purely an animation so it disappears once it's played this.remove_tile(actor); continue; } this._set_tile_prop(actor, 'previous_cell', null); this._set_tile_prop(actor, 'animation_progress', null); this._set_tile_prop(actor, 'animation_speed', null); if (! this.compat.tiles_react_instantly) { // We need to track the actor AND the cell explicitly, because it's possible // that one actor's step will cause another actor to start another move, and // then they'd end up stepping on the new cell they're moving to instead of // the one they just landed on! cell_steppers.push([actor, actor.cell]); } } } } for (let [actor, cell] of cell_steppers) { this.step_on_cell(actor, cell); } // Now we handle wiring this.update_wiring(); // Only reset the player's is_pushing between movement, so it lasts for the whole push if (this.player.movement_cooldown <= 0) { this.player.is_pushing = false; } } advance_tic_act(p1_primary_direction, p1_secondary_direction) { // Second pass: actors decide their upcoming movement simultaneously for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; if (! actor.cell) continue; if (actor.movement_cooldown > 0) continue; // Teeth can only move the first 4 of every 8 tics, though "first" // can be adjusted if (actor.slide_mode === null && actor.type.uses_teeth_hesitation && (this.tic_counter + this.step_parity) % 8 >= 4) { continue; } let direction_preference; 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 actor.decision = actor.pending_push; this._set_tile_prop(actor, 'pending_push', null); continue; } if (actor.slide_mode === 'ice') { // Actors can't make voluntary moves on ice; they just slide actor.decision = actor.direction; continue; } else if (actor.slide_mode === 'force') { // Only the player can make voluntary moves on a force floor, // and only if their previous move was an /involuntary/ move on // a force floor. If they do, it overrides the forced move // XXX this in particular has some subtleties in lynx (e.g. you // can override forwards??) and DEFINITELY all kinds of stuff // in ms if (actor === this.player && p1_primary_direction && actor.last_move_was_force) { actor.decision = p1_primary_direction; this._set_tile_prop(actor, 'last_move_was_force', false); } else { actor.decision = actor.direction; if (actor === this.player) { this._set_tile_prop(actor, 'last_move_was_force', true); } } continue; } else if (actor === this.player) { if (p1_primary_direction) { actor.decision = p1_primary_direction; this._set_tile_prop(actor, 'last_move_was_force', false); } continue; } else 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 continue; } else if (actor.type.movement_mode === 'forward') { // blue tank behavior: keep moving forward, reverse if the flag is set let direction = actor.direction; if (actor.pending_reverse) { direction = DIRECTIONS[actor.direction].opposite; this._set_tile_prop(actor, 'pending_reverse', false); } // Tanks are controlled explicitly so they don't check if they're blocked // TODO tanks in traps turn around, but tanks on cloners do not, and i use the same // prop for both if (! actor.cell.some(tile => tile.type.name === 'cloner')) { actor.decision = direction; } continue; } else if (actor.type.movement_mode === 'follow-left') { // bug behavior: always try turning as left as possible, and // fall back to less-left turns when that fails let d = DIRECTIONS[actor.direction]; direction_preference = [d.left, actor.direction, d.right, d.opposite]; } else if (actor.type.movement_mode === 'follow-right') { // paramecium behavior: always try turning as right as // possible, and fall back to less-right turns when that fails let d = DIRECTIONS[actor.direction]; direction_preference = [d.right, actor.direction, d.left, d.opposite]; } else if (actor.type.movement_mode === 'turn-left') { // glider behavior: preserve current direction; if that doesn't // work, turn left, then right, then back the way we came let d = DIRECTIONS[actor.direction]; direction_preference = [actor.direction, d.left, d.right, d.opposite]; } else if (actor.type.movement_mode === 'turn-right') { // fireball behavior: preserve current direction; if that doesn't // work, turn right, then left, then back the way we came let d = DIRECTIONS[actor.direction]; direction_preference = [actor.direction, d.right, d.left, d.opposite]; } else if (actor.type.movement_mode === 'bounce') { // bouncy ball behavior: preserve current direction; if that // doesn't work, bounce back the way we came let d = DIRECTIONS[actor.direction]; direction_preference = [actor.direction, d.opposite]; } else if (actor.type.movement_mode === 'bounce-random') { // walker behavior: preserve current direction; if that doesn't work, pick a random // direction, even the one we failed to move in (but ONLY then) direction_preference = [actor.direction, 'WALKER']; } else if (actor.type.movement_mode === 'pursue') { // teeth behavior: always move towards the player let target_cell = this.player.cell; // CC2 behavior (not Lynx (TODO compat?)): pursue the cell the player is leaving, if // they're still mostly in it if (this.player.previous_cell && this.player.animation_speed && this.player.animation_progress <= this.player.animation_speed / 2) { target_cell = this.player.previous_cell; } let dx = actor.cell.x - target_cell.x; let dy = actor.cell.y - target_cell.y; let preferred_horizontal, preferred_vertical; if (dx > 0) { preferred_horizontal = 'west'; } else if (dx < 0) { preferred_horizontal = 'east'; } if (dy > 0) { preferred_vertical = 'north'; } else if (dy < 0) { preferred_vertical = 'south'; } // Chooses the furthest direction, vertical wins ties if (Math.abs(dx) > Math.abs(dy)) { // Horizontal first direction_preference = [preferred_horizontal, preferred_vertical].filter(x => x); } else { // Vertical first direction_preference = [preferred_vertical, preferred_horizontal].filter(x => x); } } else if (actor.type.movement_mode === 'random') { // blob behavior: move completely at random let modifier = this.get_blob_modifier(); direction_preference = [['north', 'east', 'south', 'west'][(this.prng() + modifier) % 4]]; } // Check which of those directions we *can*, probably, move in // TODO i think player on force floor will still have some issues here if (direction_preference) { let fallback_direction; for (let direction of direction_preference) { if (direction === 'WALKER') { // Walkers roll a random direction ONLY if their first attempt was blocked direction = actor.direction; let num_turns = this.prng() % 4; for (let i = 0; i < num_turns; i++) { direction = DIRECTIONS[direction].right; } } fallback_direction = direction; let dest_cell = this.get_neighboring_cell(actor.cell, direction); if (! dest_cell) continue; if (! actor.cell.blocks_leaving(actor, direction) && ! dest_cell.blocks_entering(actor, direction, this, true)) { // We found a good direction! Stop here actor.decision = direction; break; } } // If all the decisions are blocked, actors still try the last one (and might even // be able to move that way by the time their turn comes around!) if (actor.decision === null) { actor.decision = fallback_direction; } } } // Third pass: everyone actually moves for (let i = this.actors.length - 1; i >= 0; i--) { let actor = this.actors[i]; if (! actor.cell) continue; // Check this again, because one actor's movement might caused a later actor to move // (e.g. by pressing a red or brown button) if (actor.movement_cooldown > 0) continue; if (! actor.decision) continue; let old_cell = actor.cell; let success = this.attempt_step(actor, actor.decision); // Track whether the player is blocked, for visual effect if (actor === this.player && p1_primary_direction && ! success) { this.sfx.play_once('blocked'); actor.is_blocked = true; } // Players can also bump the tiles in the cell next to the one they're leaving let dir2 = actor.secondary_direction; if (actor.type.is_player && dir2 && ! old_cell.blocks_leaving(actor, dir2)) { let neighbor = this.get_neighboring_cell(old_cell, dir2); if (neighbor) { let could_push = ! neighbor.blocks_entering(actor, dir2, this, true); for (let tile of Array.from(neighbor)) { if (tile.type.on_bump) { tile.type.on_bump(tile, this, actor); } if (could_push && actor.can_push(tile, dir2)) { // Block slapping: you can shove a block by walking past it sideways // TODO i think cc2 uses the push pose and possibly even turns you here? this.attempt_step(tile, dir2); } } } } } // 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 let p = 0; for (let i = 0, l = this.actors.length; i < l; i++) { let actor = this.actors[i]; if (actor.cell) { if (p !== i) { this.actors[p] = actor; } p++; } else { let local_p = p; this.pending_undo.push(() => this.actors.splice(local_p, 0, actor)); } } this.actors.length = p; // Advance the clock let tic_counter = this.tic_counter; 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'); } } } // 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; this.set_actor_direction(actor, direction); // Record our speed, and halve it below if we're stepping onto a sliding tile let speed = actor.type.movement_speed; let move = DIRECTIONS[direction].movement; if (!actor.cell) console.error(actor); let goal_cell = this.get_neighboring_cell(actor.cell, direction); // TODO this could be a lot simpler if i could early-return! should ice bumping be // somewhere else? let blocked; if (goal_cell) { // Only bother touching the goal cell if we're not already trapped in this one if (actor.cell.blocks_leaving(actor, direction)) { blocked = true; } // (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.) // FIXME actually, this prevents flicking! if (! blocked) { // Try to move into the cell. This is usually a simple check of whether we can // enter it (similar to Cell.blocks_entering), but if the only thing blocking us is // a pushable object, we have to do two more passes: one to push anything pushable, // then one to check whether we're blocked again. let has_slide_tile = false; let blocked_by_pushable = false; for (let tile of goal_cell) { if (tile.blocks(actor, direction, this)) { if (actor.can_push(tile, direction)) { blocked_by_pushable = true; } else { blocked = true; // Don't break here, because we might still want to bump other tiles } } if (actor.ignores(tile.type.name)) continue; if (tile.type.slide_mode) { has_slide_tile = true; } // Bump tiles that we're even attempting to move into; this mostly reveals // invisible walls, blue floors, etc. if (tile.type.on_bump) { tile.type.on_bump(tile, this, actor); } } if (has_slide_tile) { speed /= 2; } // If the only thing blocking us can be pushed, give that a shot if (! blocked && blocked_by_pushable) { // This time make a copy, since we're modifying the contents of the cell for (let tile of Array.from(goal_cell)) { if (actor.can_push(tile, direction)) { if (! this.attempt_step(tile, direction) && tile.slide_mode !== null && tile.movement_cooldown !== 0) { // If the push failed and the obstacle is in the middle of a slide, // remember this as the next move it'll make this._set_tile_prop(tile, 'pending_push', direction); } if (actor === this.player) { actor.is_pushing = true; } } } // Now check if we're still blocked blocked = goal_cell.blocks_entering(actor, direction, this); } } } else { // Hit the edge blocked = true; } if (blocked) { if (actor.slide_mode === 'ice') { // Actors on ice turn around when they hit something this.set_actor_direction(actor, DIRECTIONS[direction].opposite); } if (actor.slide_mode !== null) { // Somewhat clumsy hack: if an actor is sliding and hits something, step on the // relevant tile again. This fixes two problems: if it was on an ice corner then it // needs to turn a second time even though it didn't move; and if it was a player // overriding a force floor into a wall, then their direction needs to be set back // to the force floor direction. // (For random force floors, this does still match CC2 behavior: after an override, // CC2 will try to force you in the /next/ RFF direction.) // FIXME now overriding into a wall doesn't show you facing that way at all! lynx // only changes your direction at decision time by examining the floor tile... for (let tile of actor.cell) { if (tile.type.slide_mode === actor.slide_mode && tile.type.on_arrive) { tile.type.on_arrive(tile, this, actor); } } } return false; } // We're clear! this.move_to(actor, goal_cell, speed); // Set movement cooldown since we just moved this._set_tile_prop(actor, 'movement_cooldown', speed); return true; } // 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; this._set_tile_prop(actor, 'previous_cell', actor.cell); this._set_tile_prop(actor, 'animation_speed', speed); this._set_tile_prop(actor, 'animation_progress', 0); let original_cell = actor.cell; this.remove_tile(actor); this.make_slide(actor, null); 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 // TODO do blocks smash monsters? if (actor === this.player) { this.hint_shown = null; } for (let tile of goal_cell) { if (actor.type.is_player && tile.type.is_monster) { this.fail(tile.type.name); } else if (actor.type.is_monster && tile.type.is_player) { this.fail(actor.type.name); } else if (actor.type.is_block && tile.type.is_player) { this.fail('squished'); } if (tile.type.slide_mode && ! actor.ignores(tile.type.name)) { this.make_slide(actor, tile.type.slide_mode); } if (actor === this.player && tile.type.is_hint) { this.hint_shown = tile.specific_hint ?? this.stored_level.hint; } } // If we're stepping directly on the player, that kills them too // TODO this only works because i have the player move first; in lynx the check is the other // way around if (actor.type.is_monster && goal_cell === this.player_leaving_cell) { this.fail(actor.type.name); } if (actor === this.player && goal_cell[0].type.name === 'floor') { this.sfx.play_once('step-floor'); } 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) { let teleporter; for (let tile of Array.from(cell)) { if (tile === actor) continue; if (actor.ignores(tile.type.name)) continue; if (tile.type.is_item && (actor.type.has_inventory || 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) { teleporter = tile; } else if (tile.type.on_arrive) { tile.type.on_arrive(tile, this, actor); } } // Handle teleporting, now that the dust has cleared // FIXME something funny happening here, your input isn't ignored while walking out of it? if (teleporter) { let original_direction = actor.direction; let success = false; for (let dest of teleporter.type.teleport_dest_order(teleporter, this, actor)) { // Teleporters already containing an actor are blocked and unusable if (dest.cell.some(tile => tile.type.is_actor && tile !== actor)) continue; // Physically move the actor to the new teleporter // XXX lynx treats this as a slide and does it in a pass in the main loop // XXX not especially undo-efficient this.remove_tile(actor); this.add_tile(actor, dest.cell); // Red and green teleporters attempt to spit you out in every direction before // giving up on a destination (but not if you return to the original). // Note that we use actor.direction here (rather than original_direction) because // green teleporters modify it in teleport_dest_order, to randomize the exit // direction let direction = actor.direction; let num_directions = 1; if (teleporter.type.teleport_try_all_directions && dest !== teleporter) { num_directions = 4; } for (let i = 0; i < num_directions; i++) { if (this.attempt_step(actor, direction)) { 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; } else { direction = DIRECTIONS[direction].right; } } if (success) { break; } else if (num_directions === 4) { // Restore our original facing before continuing // (For red teleports, we try every possible destination in our original // movement direction, so this is correct. For green teleports, we only try one // destination and then fall back to walking through the source in our original // movement direction, so this is still correct.) this.set_actor_direction(actor, original_direction); } } } } // Update the state of all wired tiles in the game. // XXX need to be clear on the order of events here. say everything starts out unpowered. // then: // 1. you step on a pink button, which flags itself as going to be powered next frame // 2. this pass happens. every unpowered-but-wired cell is inspected. if a powered one is // found, floodfill from there // FIXME can probably skip this if we know there are no wires at all, like in a CCL, or just an // unwired map // FIXME this feels inefficient. most of the time none of the inputs have changed so none of // this needs to happen at all // FIXME none of this is currently undoable update_wiring() { // Gather every tile that's emitting power. Along the way, check whether any of them have // changed since last tic, so we can skip this work entirely if none did let neighbors = []; let any_changed = false; for (let tile of this.power_sources) { if (! tile.cell) continue; let emitting = tile.type.get_emitting_edges(tile, this); if (emitting) { neighbors.push([tile.cell, emitting]); } if (emitting !== tile.emitting_edges) { any_changed = true; tile.emitting_edges = emitting; } } // Also check actors, since any of them might be holding a lightning bolt (argh) for (let actor of this.actors) { if (! actor.cell) continue; // Only count when they're on a tile, not in transit! // FIXME this causes us to power mechanisms next to us, but we're only supposed to touch // wire we're standing on? let emitting = actor.movement_cooldown === 0 && actor.has_item('lightning_bolt'); if (emitting) { neighbors.push([actor.cell, 0x0f]); } if (emitting !== actor.emitting_edges) { any_changed = true; actor.emitting_edges = emitting; } } // If none changed, we're done if (! any_changed) return; // Turn off power to every cell // TODO wonder if i need a linear cell list, or even a flat list of all tiles (that sounds // like hell to keep updated though) for (let row of this.cells) { for (let cell of row) { cell.prev_powered_edges = cell.powered_edges; cell.powered_edges = 0; } } // Iterate over emitters and flood-fill outwards one edge at a time // propagated it via flood-fill through neighboring wires while (neighbors.length > 0) { let [cell, source_direction] = neighbors.shift(); let wire = cell.get_wired_tile(); // Power this cell if (typeof(source_direction) === 'number') { // This cell is emitting power itself, and the source direction is actually a // bitmask of directions cell.powered_edges = source_direction; } else { let bit = DIRECTIONS[source_direction].bit; if (wire === null || (wire.wire_directions & bit) === 0) { // No wire on this side, so the power doesn't actually propagate, but it DOES // stay on this edge (so if this is e.g. a purple tile, it'll be powered) cell.powered_edges |= bit; continue; } // Common case: power entering a wired edge and propagating outwards. The only // special case is that four-way wiring is two separate wires, N/S and E/W if (wire.wire_directions === 0x0f) { cell.powered_edges |= bit; cell.powered_edges |= DIRECTIONS[DIRECTIONS[source_direction].opposite].bit; } else { cell.powered_edges = wire.wire_directions; } } // Propagate current to neighbors for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) { if (direction === source_direction) continue; if ((cell.powered_edges & dirinfo.bit) === 0) continue; let neighbor, neighbor_wire; let opposite_bit = DIRECTIONS[dirinfo.opposite].bit; if (wire && (wire.wire_tunnel_directions & dirinfo.bit)) { // Search in the given direction until we find a matching tunnel // FIXME these act like nested parens! let x = cell.x; let y = cell.y; let nesting = 0; while (true) { x += dirinfo.movement[0]; y += dirinfo.movement[1]; if (! this.is_point_within_bounds(x, y)) break; let candidate = this.cells[y][x]; neighbor_wire = candidate.get_wired_tile(); if (neighbor_wire && ((neighbor_wire.wire_tunnel_directions ?? 0) & opposite_bit)) { neighbor = candidate; break; } } } else { // No tunnel; this is easy neighbor = this.get_neighboring_cell(cell, direction); if (neighbor) { neighbor_wire = neighbor.get_wired_tile(); } } if (neighbor && (neighbor.powered_edges & opposite_bit) === 0 && // Unwired tiles are OK; they might be something activated by power. // Wired tiles that do NOT connect to us are ignored. (! neighbor_wire || neighbor_wire.wire_directions & opposite_bit)) { neighbors.push([neighbor, dirinfo.opposite]); } } } // Inform any affected cells of power changes for (let row of this.cells) { for (let cell of row) { if ((cell.prev_powered_edges === 0) !== (cell.powered_edges === 0)) { let method = cell.powered_edges ? 'on_power' : 'on_depower'; for (let tile of cell) { if (tile.type[method]) { tile.type[method](tile, this); } } } } } } // Performs a depth-first search for connected wires and wire objects, extending out from the // given starting cell *follow_circuit(cell) { } // ------------------------------------------------------------------------- // Board inspection is_point_within_bounds(x, y) { return (x >= 0 && x < this.width && y >= 0 && y < this.height); } get_neighboring_cell(cell, direction) { let move = DIRECTIONS[direction].movement; let goal_x = cell.x + move[0]; let goal_y = cell.y + move[1]; if (this.is_point_within_bounds(goal_x, goal_y)) { return this.cells[goal_y][goal_x]; } else { return null; } } // 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 x = start_cell.x; let y = start_cell.y; while (true) { if (reverse) { x -= 1; if (x < 0) { x = this.width - 1; y = (y - 1 + this.height) % this.height; } } else { x += 1; if (x >= this.width) { x = 0; y = (y + 1) % this.height; } } let cell = this.cells[y][x]; 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); 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++) { if (this.is_point_within_bounds(sx, sy)) { yield this.cells[sy][sx]; } sx += direction[0]; sy += direction[1]; } } } } // ------------------------------------------------------------------------- // 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() { 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; } } // ------------------------------------------------------------------------- // 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 (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.ceil(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 (this.timer_paused) { this.time_remaining = 1; } else { this.fail('time'); } } } fail(reason) { if (reason === 'time') { this.sfx.play_once('timeup'); } else { this.sfx.play_once('lose'); } this.pending_undo.push(() => { this.fail_reason = null; this.player.fail_reason = null; }); this.state = 'failure'; this.fail_reason = reason; this.player.fail_reason = reason; throw new GameEnded; } win() { this.sfx.play_once('win'); this.state = 'success'; throw new GameEnded; } 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.pending_undo.push(() => cell._add(tile, index)); } add_tile(tile, cell, index = null) { cell._add(tile, index); this.pending_undo.push(() => cell._remove(tile)); } add_actor(actor) { this.actors.push(actor); this.pending_undo.push(() => this.actors.pop()); } spawn_animation(cell, name) { let type = TILE_TYPES[name]; let tile = new Tile(type); this._set_tile_prop(tile, 'animation_speed', tile.type.ttl); this._set_tile_prop(tile, 'animation_progress', 0); cell._add(tile); this.actors.push(tile); this.pending_undo.push(() => { this.actors.pop(); cell._remove(tile); }); } transmute_tile(tile, name) { let current = tile.type.name; this.pending_undo.push(() => 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, 'animation_speed', tile.type.ttl); this._set_tile_prop(tile, 'animation_progress', 0); } } // 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)) { 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) { 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.pending_undo.push(() => actor.keyring[name] -= 1); } else { // tool, presumably if (! actor.toolbelt) { actor.toolbelt = []; } actor.toolbelt.push(name); this.pending_undo.push(() => actor.toolbelt.pop()); } 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.pending_undo.push(() => actor.keyring[name] += 1); actor.keyring[name] -= 1; return true; } return false; } take_all_keys_from_actor(actor) { if (actor.keyring) { let keyring = actor.keyring; this.pending_undo.push(() => actor.keyring = keyring); actor.keyring = {}; } } take_all_tools_from_actor(actor) { if (actor.toolbelt) { let toolbelt = actor.toolbelt; this.pending_undo.push(() => actor.toolbelt = toolbelt); actor.toolbelt = []; } } // 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); } }