import { DIRECTIONS } from './defs.js'; import TILE_TYPES from './tiletypes.js'; export class Tile { constructor(type, direction = 'south') { this.type = type; 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) { if (this.type.blocks_all) return true; if (this.type.thin_walls && this.type.thin_walls.has(DIRECTIONS[direction].opposite)) return true; if (other.type.is_player && this.type.blocks_players) return true; if (other.type.is_monster && this.type.blocks_monsters) return true; if (other.type.is_block && this.type.blocks_blocks) 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) { return ( this.type.pushes && this.type.pushes[tile.type.name] && tile.movement_cooldown === 0 && ! tile.stuck); } // 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); } } } 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; } blocks_leaving(actor, direction) { for (let tile of this) { if (tile !== actor && ! tile.type.is_swivel && tile.type.thin_walls && tile.type.thin_walls.has(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))) { return true; } } return false; } } Cell.prototype.was_powered = false; Cell.prototype.is_powered = false; class GameEnded extends Error {} 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; this.undo_stack = []; this.pending_undo = []; let n = 0; let connectables = []; // Handle CC2 wiring; a contiguous region of wire is all updated as a single unit, so detect // those units ahead of time for simplicity and call them "clusters" this.wire_clusters = []; // 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++; let has_cloner, has_trap, has_forbidden; 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; } // TODO well this is pretty special-casey. maybe come up // with a specific pass at the beginning of the level? // TODO also assumes a specific order... if (tile.type.name === 'cloner') { has_cloner = true; } if (tile.type.name === 'trap') { has_trap = true; } 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) { if (has_cloner) { // TODO is there any reason not to add clone templates to the actor // list? tile.stuck = true; } } if (! tile.stuck) { 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; let goal = connectable.type.connects_to; // Check for custom wiring, for MSCC .DAT levels if (this.stored_level.has_custom_connections) { let n = this.stored_level.coords_to_scalar(x, y); let target_cell_n = null; if (goal === 'trap') { target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null; } else if (goal === 'cloner') { 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 (tile.type.name === goal) { connectable.connection = tile; break; } } } continue; } // Otherwise, look in reading order for (let tile of this.iter_tiles_in_reading_order(cell, goal)) { // 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; } } } } } // Lynx PRNG, used unchanged in CC2 prng() { // TODO what if we just saved this stuff, as well as the RFF direction, at the beginning of // each tic? let rng1 = this._rng1; let rng2 = this._rng2; this.pending_undo.push(() => { this._rng1 = rng1; this._rng2 = rng2; }); 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; console.log(ret); return ret; } // Move the game state forwards by one tic advance_tic(p1_primary_direction, p1_secondary_direction) { if (this.state !== 'playing') { console.warn(`Level.advance_tic() called when state is ${this.state}`); return; } try { this._advance_tic(p1_primary_direction, p1_secondary_direction); } 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 this.commit(); } _advance_tic(p1_primary_direction, p1_secondary_direction) { // 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_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_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1); } if (actor.animation_speed) { // Deal with movement animation this._set_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_prop(actor, 'previous_cell', null); this._set_prop(actor, 'animation_progress', null); this._set_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; } // 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_prop(actor, 'pending_reverse', false); } 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_prop(actor, 'last_move_was_force', false); } else { actor.decision = actor.direction; if (actor === this.player) { this._set_prop(actor, 'last_move_was_force', true); } } continue; } else if (actor === this.player) { if (p1_primary_direction) { actor.decision = p1_primary_direction; this._set_prop(actor, 'last_move_was_force', false); } 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_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 // TODO unclear if this is right in cc2 as well. definitely not in ms, which chooses a legal move direction_preference = [actor.direction, ['north', 'south', 'east', 'west'][Math.floor(Math.random() * 4)]]; } 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 // TODO cc2 has twiddles for how this works per-level, as well as the initial seed for demo playback direction_preference = [['north', 'south', 'east', 'west'][Math.floor(Math.random() * 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 && ! actor.stuck) { for (let direction of direction_preference) { 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; } } } } // 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)) { // 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) { let time_remaining = this.time_remaining; this.pending_undo.push(() => { this.tic_counter = tic_counter; this.time_remaining = time_remaining; }); 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'); } } else { this.pending_undo.push(() => { this.tic_counter = tic_counter; }); } } // 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); if (actor.stuck) return false; // 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)) { 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)) { this.attempt_step(tile, 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_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_prop(actor, 'previous_cell', actor.cell); this._set_prop(actor, 'animation_speed', speed); this._set_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._set_prop(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._set_prop(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; // TODO some actors can pick up some items... if (actor.type.is_player && tile.type.is_item && 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() { // 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.was_powered = cell.is_powered; cell.is_powered = false; } } // Iterate through the grid looking for emitters — tiles that are generating current — and // propagated it via flood-fill through neighboring wires for (let row of this.cells) { for (let cell of row) { // TODO probably this should set a prop on the tile if (! cell.some(tile => tile.type.is_emitting && tile.type.is_emitting(tile, this))) continue; // We have an emitter! Flood-fill outwards let neighbors = [cell]; for (let neighbor of neighbors) { // Power it even if it's not wired itself, so that e.g. purple tiles work neighbor.is_powered = true; let wire = neighbor.get_wired_tile(); if (! wire) continue; // Emit along every wire direction, and add any unpowered neighbors to the // pending list to continue the floodfill // TODO but only if wires connect // TODO handle wire tunnels for (let [direction, dirinfo] of Object.entries(DIRECTIONS)) { if (! (wire.wire_directions & dirinfo.bit)) continue; let neighbor2, wire2; let opposite_bit = DIRECTIONS[dirinfo.opposite].bit; if (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 = neighbor.x; let y = neighbor.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]; wire2 = candidate.get_wired_tile(); if (wire2 && (wire2.wire_tunnel_directions ?? 0) & opposite_bit) { neighbor2 = candidate; break; } } } else { // Otherwise this is easy neighbor2 = this.get_neighboring_cell(neighbor, direction); wire2 = neighbor2.get_wired_tile(); } if (neighbor2 && ! neighbor2.is_powered && // Unwired tiles are OK; they might be something activated by power. // Wired tiles that do NOT connect to us are ignored. (! wire2 || wire2.wire_directions & opposite_bit)) { neighbors.push(neighbor2); } } } } } // Inform any affected cells of power changes for (let row of this.cells) { for (let cell of row) { if (cell.was_powered !== cell.is_powered) { let method = cell.is_powered ? '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; } } // ------------------------------------------------------------------------- // Undo handling commit() { this.undo_stack.push(this.pending_undo); this.pending_undo = []; // Limit the stack to, idk, 200 tics (10 seconds) if (this.undo_stack.length > 200) { this.undo_stack.splice(0, this.undo_stack.length - 200); } } undo() { this.aid = Math.max(1, this.aid); let entry = this.undo_stack.pop(); // Undo in reverse order! There's no redo, so it's okay to destroy this entry.reverse(); for (let undo of entry) { undo(); } } // ------------------------------------------------------------------------- // 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_prop(obj, key, val) { let old_val = obj[key]; if (val === old_val) return; this.pending_undo.push(() => obj[key] = old_val); obj[key] = val; } collect_chip() { let current = this.chips_remaining; if (current > 0) { this.sfx.play_once('get-chip'); this.pending_undo.push(() => this.chips_remaining = current); this.chips_remaining--; } } adjust_bonus(add, mult = 1) { let current = this.bonus_points; this.pending_undo.push(() => this.bonus_points = current); this.bonus_points = Math.ceil(this.bonus_points * mult) + add; } pause_timer() { if (this.time_remaining === null) return; this.pending_undo.push(() => this.timer_paused = ! this.timer_paused); this.timer_paused = ! this.timer_paused; } adjust_timer(dt) { let current = this.time_remaining; this.pending_undo.push(() => this.time_remaining = current); // 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.state = 'playing'; 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.pending_undo.push(() => this.state = 'playing'); 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? 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_prop(tile, 'animation_speed', tile.type.ttl); this._set_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_prop(tile, 'animation_speed', tile.type.ttl); this._set_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) { if (! tile.cell.some(t => t.type.disables_pickup) && this.give_actor(actor, tile.type.name)) { this.remove_tile(tile); 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) { let current = actor.slide_mode; this.pending_undo.push(() => actor.slide_mode = current); actor.slide_mode = mode; } // Change an actor's direction set_actor_direction(actor, direction) { let current = actor.direction; this.pending_undo.push(() => actor.direction = current); actor.direction = direction; } set_actor_stuck(actor, is_stuck) { let current = actor.stuck; if (current === is_stuck) return; this.pending_undo.push(() => actor.stuck = current); actor.stuck = is_stuck; } }