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; this.slide_mode = null; this.movement_cooldown = 0; if (type.has_inventory) { this.inventory = {}; } } static from_template(tile_template) { let type = TILE_TYPES[tile_template.name]; if (! type) console.error(tile_template.name); let tile = new this(type, tile_template.direction); if (type.load) { type.load(tile, tile_template); } return tile; } // 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.inventory) { for (let [item, count] of Object.entries(this.inventory)) { if (count === 0) continue; 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) { return this.inventory[name] ?? 0 > 0; } // TODO remove, not undoable take_item(name, amount = null) { if (this.inventory[name] && this.inventory[name] >= 1) { if (amount == null && this.type.infinite_items && this.type.infinite_items[name]) { // Some items can't be taken away normally, by which I mean, // green keys ; } else { this.inventory[name] = Math.max(0, this.inventory[name] - (amount || 1)); } return true; } else { return false; } } } 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; } 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) && ! actor.ignores(tile.type.name) && ! (ignore_pushables && actor.can_push(tile))) { return true; } } return false; } } 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 = {}; // 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; // 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; 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'; this.undo_stack = []; this.pending_undo = []; let n = 0; let connectables = []; // 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 chip this.player = tile; // Always put the player at the start of the actor list // (accomplished traditionally with a swap) this.actors.push(this.actors[0]); this.actors[0] = tile; } else if (tile.type.is_actor) { if (has_cloner) { tile.stuck = true; } else { if (has_trap) { // FIXME wait, not if the trap is open! crap tile.stuck = true; } 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; let found = false; // Check for custom wiring, for MSCC .DAT levels let n = x + y * this.width; 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) { // TODO this N could be outside the map bounds let target_cell_x = target_cell_n % this.width; let target_cell_y = Math.floor(target_cell_n / this.width); for (let tile of this.cells[target_cell_y][target_cell_x]) { if (tile.type.name === goal) { connectable.connection = tile; found = true; break; } } if (found) continue; } // Otherwise, look in reading order let direction = 1; if (connectable.type.connect_order === 'backward') { direction = -1; } for (let i = 0; i < num_cells - 1; i++) { x += direction; if (x >= this.width) { x -= this.width; y = (y + 1) % this.height; } else if (x < 0) { x += this.width; y = (y - 1 + this.height) % this.height; } for (let tile of this.cells[y][x]) { if (tile.type.name === goal) { // TODO should be weak, but you can't destroy cloners so in practice not a concern connectable.connection = tile; found = true; break; } } if (found) break; } // TODO soft warn for e.g. a button with no cloner? (or a cloner with no button?) } } // 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; } // 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; // XXX this entire turn order is rather different in ms rules // FIXME OK, do a pass to make everyone decide their movement, and then actually do it. the question iiis, where does that fit in with animation // First pass: tick cooldowns and animations; have actors arrive in their cells for (let actor of this.actors) { // Actors with no cell were destroyed if (! actor.cell) continue; // 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) { this.step_on_cell(actor); } } } } // Second pass: actors decide their upcoming movement simultaneously for (let actor of this.actors) { // Note that this prop is only used internally within a single iteration of this loop, // so it doesn't need to be undoable actor.decision = null; if (! actor.cell) continue; if (actor.movement_cooldown > 0) continue; // XXX does the cooldown drop while in a trap? is this even right? if (actor.stuck && ! actor.type.is_player) continue; // Teeth can only move the first 4 of every 8 tics, though "first" // can be adjusted if (actor.type.uses_teeth_hesitation && (this.tic_counter + this.step_parity) % 8 >= 4) continue; let direction_preference; // Actors can't make voluntary moves on ice, so they're stuck with // whatever they've got if (actor.slide_mode === 'ice') { direction_preference = [actor.direction]; } 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) { direction_preference = [p1_primary_direction]; this._set_prop(actor, 'last_move_was_force', false); } else { direction_preference = [actor.direction]; if (actor === this.player) { this._set_prop(actor, 'last_move_was_force', true); } } } else if (actor === this.player) { if (p1_primary_direction) { direction_preference = [p1_primary_direction]; this._set_prop(actor, 'last_move_was_force', false); } } else if (actor.type.movement_mode === 'forward') { // blue tank behavior: keep moving forward direction_preference = [actor.direction]; } 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 dx = actor.cell.x - this.player.cell.x; let dy = actor.cell.y - this.player.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) { // Players and sliding actors always move the way they want, even if blocked if (actor.type.is_player || actor.slide_mode) { actor.decision = direction_preference[0]; continue; } for (let direction of direction_preference) { let dest_cell = this.cell_with_offset(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 actor of this.actors) { if (! actor.cell) continue; if (! actor.decision) continue; this.set_actor_direction(actor, actor.decision); let old_cell = actor.cell; this.attempt_step(actor, actor.decision); // TODO do i need to do this more aggressively? if (this.state === 'success' || this.state === 'failure') break; // 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.cell_with_offset(old_cell, dir2); if (neighbor && ! 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 (actor.can_push(tile)) { // Block slapping: you can shove a block by walking past it sideways this.set_actor_direction(tile, dir2); 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's up!"); } } else { this.pending_undo.push(() => { this.tic_counter = tic_counter; }); } // Commit the undo state at the end of each tic this.commit(); } // Try to move the given actor one tile in the given direction and update // their cooldown. Return true if successful. attempt_step(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.cell_with_offset(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 (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 (tile.blocks(actor, direction, this)) { if (actor.can_push(tile)) { blocked_by_pushable = true; } else { blocked = true; break; } } } 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.set_actor_direction(tile, direction); this.attempt_step(tile, direction); } } // 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); // Somewhat clumsy hack: step on the ice tile again, so if it's // a corner, it'll turn us in the correct direction for (let tile of actor.cell) { if (tile.type.slide_mode === 'ice' && 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; actor.previous_cell = actor.cell; actor.animation_speed = speed; 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 i guess this covers blocks too // TODO do blocks smash monsters? for (let tile of goal_cell) { if (tile.type.slide_mode && ! actor.ignores(tile.type.name)) { this.make_slide(actor, tile.type.slide_mode); } if ((actor.type.is_player && tile.type.is_monster) || (actor.type.is_monster && tile.type.is_player)) { // TODO ooh, obituaries this.fail("Oops! Watch out for creatures!"); return; } if (actor.type.is_block && tile.type.is_player) { // TODO ooh, obituaries this.fail("squish"); return; } } // 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 (goal_cell === this.player_leaving_cell) { this.fail("Oops! Watch out for creatures!"); } if (this.compat.tiles_react_instantly) { this.step_on_cell(actor); } } // Step on every tile in a cell we just arrived in step_on_cell(actor) { if (actor === this.player) { this._set_prop(this, 'hint_shown', null); } let teleporter; for (let tile of Array.from(actor.cell)) { if (tile === actor) continue; if (actor.ignores(tile.type.name)) continue; if (actor === this.player && tile.type.is_hint) { this._set_prop(this, 'hint_shown', tile.specific_hint ?? this.stored_level.hint); } if (tile.type.is_item && this.give_actor(actor, tile.type.name)) { this.remove_tile(tile); } else if (tile.type.is_teleporter) { 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 ignore while walking out of it? if (teleporter) { let goal = teleporter.connection; // TODO in pathological cases this might infinite loop while (true) { // Physically move the actor to the new teleporter // XXX is this right, compare with tile world? i overhear it's actually implemented as a slide? // XXX not especially undo-efficient this.remove_tile(actor); this.add_tile(actor, goal.cell); if (this.attempt_step(actor, actor.direction)) // Success, teleportation complete break; if (goal === teleporter) // We've tried every teleporter, including the one they // stepped on, so leave them on it break; // Otherwise, try the next one goal = goal.connection; } } } cell_with_offset(cell, direction) { let move = DIRECTIONS[direction].movement; let goal_x = cell.x + move[0]; let goal_y = cell.y + move[1]; if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) { return this.cells[goal_y][goal_x]; } else { return null; } } // ------------------------------------------------------------------------- // 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() { 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.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's up!"); } } } fail(message) { this.pending_undo.push(() => { this.state = 'playing'; this.fail_message = null; }); this.state = 'failure'; this.fail_message = message; } win() { this.pending_undo.push(() => this.state = 'playing'); this.state = 'success'; } // 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); tile.animation_speed = type.ttl; 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); } } give_actor(actor, name) { if (! actor.type.has_inventory) return false; let current = actor.inventory[name]; this.pending_undo.push(() => actor.inventory[name] = current); actor.inventory[name] = (current ?? 0) + 1; return true; } // 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; } }