From 549b34ad301b5f90ba90a23c7ce26963c8b1160b Mon Sep 17 00:00:00 2001 From: "Eevee (Evelyn Woods)" Date: Thu, 10 Sep 2020 11:05:38 -0600 Subject: [PATCH] Split out the game proper --- js/game.js | 864 ++++++++++++++++++++++++++++++++++++++++++++++++++++ js/main.js | 867 +---------------------------------------------------- 2 files changed, 866 insertions(+), 865 deletions(-) create mode 100644 js/game.js diff --git a/js/game.js b/js/game.js new file mode 100644 index 0000000..e8e30b7 --- /dev/null +++ b/js/game.js @@ -0,0 +1,864 @@ +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) { + 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; + + 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; + } + + // Inventory stuff + give_item(name) { + this.inventory[name] = (this.inventory[name] ?? 0) + 1; + } + + 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; + } +} + +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; + if (this.stored_level.time_limit === 0) { + this.time_remaining = null; + } + else { + this.time_remaining = this.stored_level.time_limit; + } + this.bonus_points = 0; + 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(player_direction) { + if (this.state !== 'playing') { + console.warn(`Level.advance_tic() called when state is ${this.state}`); + return; + } + + // 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 + 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 + 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; + } + actor.previous_cell = null; + actor.animation_progress = null; + actor.animation_speed = null; + if (! this.compat.tiles_react_instantly) { + this.step_on_cell(actor); + // May have been destroyed here, too! + if (! actor.cell) + continue; + } + } + } + + if (actor.movement_cooldown > 0) + continue; + + // XXX does the cooldown drop while in a trap? is this even right? + // TODO should still attempt to move (so chip turns), just will be stuck (but wait, do monsters turn? i don't think so) + if (actor.stuck) + 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 && + player_direction && + actor.last_move_was_force) + { + direction_preference = [player_direction, actor.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 (player_direction) { + direction_preference = [player_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)]]; + } + + if (! direction_preference) + continue; + + let moved = false; + for (let direction of direction_preference) { + this.set_actor_direction(actor, direction); + if (this.attempt_step(actor, direction)) { + moved = true; + break; + } + } + + // TODO do i need to do this more aggressively? + if (this.state === 'success' || this.state === 'failure') + break; + } + + // Pass time + let tic_counter = this.tic_counter; + let time_remaining = this.time_remaining; + this.tic_counter++; + if (this.time_remaining !== null && this.tic_counter % 20 === 0) { + // 20 tics means one second! Tic that time down + 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; + }); + } + + // Strip out any destroyed actors from the acting order + 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++; + } + } + this.actors.length = p; + + // 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, speed = null) { + if (actor.stuck) + return false; + + // If speed is given, we're being pushed by something so we're using + // its speed. Otherwise, use our movement speed. If we're moving onto + // a sliding tile, we'll halve it later + let check_for_slide = false; + if (speed === null) { + speed = actor.type.movement_speed; + check_for_slide = true; + } + + let move = DIRECTIONS[direction].movement; + let original_cell = actor.cell; + if (!original_cell) console.error(actor); + let goal_x = original_cell.x + move[0]; + let goal_y = original_cell.y + move[1]; + + let blocked; + if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) { + // Check for a thin wall in our current cell first + for (let tile of original_cell) { + if (tile !== actor && + ! tile.type.is_swivel && tile.type.thin_walls && + tile.type.thin_walls.has(direction)) + { + blocked = true; + break; + } + } + + // Only bother touching the goal cell if we're not already trapped + // in this one + // (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) { + let goal_cell = this.cells[goal_y][goal_x]; + // FIXME splashes should block you (e.g. pushing a block off a + // turtle) but currently do not because of this copy; we don't + // notice a new thing was added to the tile :( + for (let tile of Array.from(goal_cell)) { + if (check_for_slide && tile.type.slide_mode && ! actor.ignores(tile.type.name)) { + check_for_slide = false; + speed /= 2; + } + + if (actor.ignores(tile.type.name)) + continue; + if (! tile.blocks(actor, direction)) + continue; + + if (actor.type.pushes && actor.type.pushes[tile.type.name] && ! tile.stuck) { + this.set_actor_direction(tile, direction); + if (this.attempt_step(tile, direction, speed)) + // It moved out of the way! + continue; + } + if (tile.type.on_bump) { + tile.type.on_bump(tile, this, actor); + if (! tile.blocks(actor, direction)) + // It became something non-blocking! + continue; + } + blocked = true; + // XXX should i break here, or bump everything? + } + } + } + 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 original_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_x, goal_y, 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, x, y, speed) { + let original_cell = actor.cell; + if (x === original_cell.x && y === original_cell.y) + return; + + actor.previous_cell = actor.cell; + actor.animation_speed = speed; + actor.animation_progress = 0; + + let goal_cell = this.cells[y][x]; + 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 (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? + let current_cell = actor.cell; + 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 will probably play badly with undo lol + let tele_cell = goal.cell; + current_cell._remove(actor); + tele_cell._add(actor); + current_cell = tele_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; + } + } + } + + // ------------------------------------------------------------------------- + // 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--; + } + } + + 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)); + } + + 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]; + // TODO adjust anything else? + } + + 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; + } +} diff --git a/js/main.js b/js/main.js index fbee82f..a4b4a63 100644 --- a/js/main.js +++ b/js/main.js @@ -1,879 +1,16 @@ // TODO bugs and quirks i'm aware of: // - steam: if a player character starts on a force floor they won't be able to make any voluntary movements until they are no longer on a force floor -import { DIRECTIONS, TICS_PER_SECOND } from './defs.js'; +import { TICS_PER_SECOND } from './defs.js'; import * as c2m from './format-c2m.js'; import * as dat from './format-dat.js'; import * as format_util from './format-util.js'; +import { Level } from './game.js'; import CanvasRenderer from './renderer-canvas.js'; import { Tileset, CC2_TILESET_LAYOUT, TILE_WORLD_TILESET_LAYOUT } from './tileset.js'; import TILE_TYPES from './tiletypes.js'; import { mk, promise_event, fetch, walk_grid } from './util.js'; const PAGE_TITLE = "Lexy's Labyrinth"; - -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) { - 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; - - 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; - } - - // Inventory stuff - give_item(name) { - this.inventory[name] = (this.inventory[name] ?? 0) + 1; - } - - 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; - } - } -} - -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; - } -} - -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; - if (this.stored_level.time_limit === 0) { - this.time_remaining = null; - } - else { - this.time_remaining = this.stored_level.time_limit; - } - this.bonus_points = 0; - 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(player_direction) { - if (this.state !== 'playing') { - console.warn(`Level.advance_tic() called when state is ${this.state}`); - return; - } - - // 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 - 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 - 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; - } - actor.previous_cell = null; - actor.animation_progress = null; - actor.animation_speed = null; - if (! this.compat.tiles_react_instantly) { - this.step_on_cell(actor); - // May have been destroyed here, too! - if (! actor.cell) - continue; - } - } - } - - if (actor.movement_cooldown > 0) - continue; - - // XXX does the cooldown drop while in a trap? is this even right? - // TODO should still attempt to move (so chip turns), just will be stuck (but wait, do monsters turn? i don't think so) - if (actor.stuck) - 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 && - player_direction && - actor.last_move_was_force) - { - direction_preference = [player_direction, actor.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 (player_direction) { - direction_preference = [player_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)]]; - } - - if (! direction_preference) - continue; - - let moved = false; - for (let direction of direction_preference) { - this.set_actor_direction(actor, direction); - if (this.attempt_step(actor, direction)) { - moved = true; - break; - } - } - - // TODO do i need to do this more aggressively? - if (this.state === 'success' || this.state === 'failure') - break; - } - - // Pass time - let tic_counter = this.tic_counter; - let time_remaining = this.time_remaining; - this.tic_counter++; - if (this.time_remaining !== null && this.tic_counter % 20 === 0) { - // 20 tics means one second! Tic that time down - 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; - }); - } - - // Strip out any destroyed actors from the acting order - 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++; - } - } - this.actors.length = p; - - // 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, speed = null) { - if (actor.stuck) - return false; - - // If speed is given, we're being pushed by something so we're using - // its speed. Otherwise, use our movement speed. If we're moving onto - // a sliding tile, we'll halve it later - let check_for_slide = false; - if (speed === null) { - speed = actor.type.movement_speed; - check_for_slide = true; - } - - let move = DIRECTIONS[direction].movement; - let original_cell = actor.cell; - if (!original_cell) console.error(actor); - let goal_x = original_cell.x + move[0]; - let goal_y = original_cell.y + move[1]; - - let blocked; - if (goal_x >= 0 && goal_x < this.width && goal_y >= 0 && goal_y < this.height) { - // Check for a thin wall in our current cell first - for (let tile of original_cell) { - if (tile !== actor && - ! tile.type.is_swivel && tile.type.thin_walls && - tile.type.thin_walls.has(direction)) - { - blocked = true; - break; - } - } - - // Only bother touching the goal cell if we're not already trapped - // in this one - // (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) { - let goal_cell = this.cells[goal_y][goal_x]; - // FIXME splashes should block you (e.g. pushing a block off a - // turtle) but currently do not because of this copy; we don't - // notice a new thing was added to the tile :( - for (let tile of Array.from(goal_cell)) { - if (check_for_slide && tile.type.slide_mode && ! actor.ignores(tile.type.name)) { - check_for_slide = false; - speed /= 2; - } - - if (actor.ignores(tile.type.name)) - continue; - if (! tile.blocks(actor, direction)) - continue; - - if (actor.type.pushes && actor.type.pushes[tile.type.name] && ! tile.stuck) { - this.set_actor_direction(tile, direction); - if (this.attempt_step(tile, direction, speed)) - // It moved out of the way! - continue; - } - if (tile.type.on_bump) { - tile.type.on_bump(tile, this, actor); - if (! tile.blocks(actor, direction)) - // It became something non-blocking! - continue; - } - blocked = true; - // XXX should i break here, or bump everything? - } - } - } - 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 original_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_x, goal_y, 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, x, y, speed) { - let original_cell = actor.cell; - if (x === original_cell.x && y === original_cell.y) - return; - - actor.previous_cell = actor.cell; - actor.animation_speed = speed; - actor.animation_progress = 0; - - let goal_cell = this.cells[y][x]; - 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 (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? - let current_cell = actor.cell; - 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 will probably play badly with undo lol - let tele_cell = goal.cell; - current_cell._remove(actor); - tele_cell._add(actor); - current_cell = tele_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; - } - } - } - - // ------------------------------------------------------------------------- - // 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--; - } - } - - 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)); - } - - 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]; - // TODO adjust anything else? - } - - 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; - } -} - - // Stackable modal overlay of some kind, usually a dialog class Overlay { constructor(conductor, root) {