diff --git a/js/main.js b/js/main.js index 17c5543..6670d96 100644 --- a/js/main.js +++ b/js/main.js @@ -69,11 +69,10 @@ async function fetch(url) { const PAGE_TITLE = "Lexy's Labyrinth"; class Tile { - constructor(type, x, y, direction = 'south') { + constructor(type, direction = 'south') { this.type = type; - this.x = x; - this.y = y; this.direction = direction; + this.cell = null; this.slide_mode = null; this.movement_cooldown = 0; @@ -83,10 +82,10 @@ class Tile { } } - static from_template(tile_template, x, y) { + static from_template(tile_template) { let type = TILE_TYPES[tile_template.name]; if (! type) console.error(tile_template.name); - let tile = new this(type, x, y, tile_template.direction); + let tile = new this(type, tile_template.direction); if (type.load) { type.load(tile, tile_template); } @@ -129,15 +128,6 @@ class Tile { return false; } - become(name) { - this.type = TILE_TYPES[name]; - // TODO adjust anything else? - } - - destroy() { - this.doomed = true; - } - // Inventory stuff give_item(name) { this.inventory[name] = (this.inventory[name] ?? 0) + 1; @@ -162,13 +152,20 @@ class Tile { } class Cell extends Array { - constructor() { + constructor(x, y) { super(); - this.is_dirty = false; + this.x = x; + this.y = y; } - _add(tile) { - this.push(tile); + _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! @@ -179,14 +176,15 @@ class Cell extends Array { throw new Error("Asked to remove tile that doesn't seem to exist"); this.splice(layer, 1); + return layer; } each(f) { - for (let i = this.length - 1; i >= 0; i--) { - if (f(this[i]) === false) + let copy = Array.from(this); + for (let i = 0, l = copy.length; i < l; i++) { + if (f(copy[i]) === false) break; } - this._gc(); } _gc() { @@ -237,13 +235,16 @@ class Level { // 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 = []; 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; + let cell = new Cell(x, y); row.push(cell); let stored_cell = this.stored_level.linear_cells[n]; @@ -251,7 +252,7 @@ class Level { let has_cloner, has_forbidden; for (let template_tile of stored_cell) { - let tile = Tile.from_template(template_tile, x, y); + 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; @@ -278,7 +279,7 @@ class Level { this.actors.push(tile); } } - cell.push(tile); + cell._add(tile); if (tile.type.connects_to) { connectables.push(tile); @@ -289,17 +290,15 @@ class Level { // Connect buttons and teleporters let num_cells = this.width * this.height; - console.log(this.stored_level.custom_trap_wiring); - console.log(this.stored_level.custom_cloner_wiring); for (let connectable of connectables) { - let x = connectable.x; - let y = connectable.y; + 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; - console.log(x, y, n); let target_cell_n = null; if (goal === 'trap') { target_cell_n = this.stored_level.custom_trap_wiring[n] ?? null; @@ -367,7 +366,7 @@ class Level { continue; if (actor.movement_cooldown > 0) { - actor.movement_cooldown -= 1; + this._set_prop(actor, 'movement_cooldown', actor.movement_cooldown - 1); if (actor.movement_cooldown > 0) continue; } @@ -394,19 +393,19 @@ class Level { actor.last_move_was_force) { direction_preference = [player_direction]; - actor.last_move_was_force = false; + this._set_prop(actor, 'last_move_was_force', false); } else { direction_preference = [actor.direction]; if (actor === this.player) { - actor.last_move_was_force = true; + this._set_prop(actor, 'last_move_was_force', true); } } } else if (actor === this.player) { if (player_direction) { direction_preference = [player_direction]; - actor.last_move_was_force = false; + this._set_prop(actor, 'last_move_was_force', false); } } else if (actor.type.movement_mode === 'forward') { @@ -452,8 +451,8 @@ class Level { } else if (actor.type.movement_mode === 'pursue') { // teeth behavior: always move towards the player - let dx = actor.x - this.player.x; - let dy = actor.y - this.player.y; + let dx = actor.cell.x - this.player.cell.x; + let dy = actor.cell.y - this.player.cell.y; // Chooses the furthest direction, vertical wins ties if (Math.abs(dx) > Math.abs(dy)) { // Horizontal @@ -485,7 +484,7 @@ class Level { let moved = false; for (let direction of direction_preference) { - actor.direction = direction; + this.set_actor_direction(actor, direction); if (this.attempt_step(actor, direction)) { moved = true; break; @@ -499,7 +498,7 @@ class Level { if (actor.slide_mode !== null) { speed_multiplier = 2; } - actor.movement_cooldown = actor.type.movement_speed / speed_multiplier; + this._set_prop(actor, 'movement_cooldown', actor.type.movement_speed / speed_multiplier); } // TODO do i need to do this more aggressively? @@ -508,6 +507,13 @@ class Level { } if (this.time_remaining !== null) { + let tic_counter = this.tic_counter; + let time_remaining = this.time_remaining; + this.pending_undo.push(() => { + this.tic_counter = tic_counter; + this.time_remaining = time_remaining; + }); + this.tic_counter++; while (this.tic_counter > 20) { this.tic_counter -= 20; @@ -517,28 +523,22 @@ class Level { } } } - } - fail(message) { - this.state = 'failure'; - this.fail_message = message; - } - - win() { - this.state = 'success'; + // 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) { let move = DIRECTIONS[direction].movement; - let goal_x = actor.x + move[0]; - let goal_y = actor.y + move[1]; + let original_cell = actor.cell; + 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 - let original_cell = this.cells[actor.y][actor.x]; original_cell.each(tile => { if (tile !== actor && tile.type.thin_walls && tile.type.thin_walls.has(direction)) @@ -594,19 +594,14 @@ class Level { // tile interactions. Does NOT check for whether the move is actually // legal; use attempt_step for that! move_to(actor, x, y) { - if (x === actor.x && y === actor.y) + let original_cell = actor.cell; + if (x === original_cell.x && y === original_cell.y) return; let goal_cell = this.cells[y][x]; - let original_cell = this.cells[actor.y][actor.x]; - original_cell._remove(actor); + this.remove_tile(actor); actor.slide_mode = null; - goal_cell._add(actor); - actor.x = x; - actor.y = y; - - original_cell.is_dirty = true; - goal_cell.is_dirty = true; + this.add_tile(actor, goal_cell); // Announce we're leaving, for the handful of tiles that care about it original_cell.each(tile => { @@ -622,7 +617,7 @@ class Level { // Step on all the tiles in the new cell if (actor === this.player) { - this.hint_shown = null; + this._set_prop(this, 'hint_shown', null); } let teleporter; goal_cell.each(tile => { @@ -632,12 +627,11 @@ class Level { return; if (actor === this.player && tile.type.is_hint) { - this.hint_shown = tile.specific_hint ?? this.stored_level.hint; + this._set_prop(this, 'hint_shown', tile.specific_hint ?? this.stored_level.hint); } - if (tile.type.is_item && actor.type.has_inventory) { - actor.give_item(tile.type.name); - tile.destroy(); + if (tile.type.is_item && this.give_actor(actor, tile.type.name)) { + this.remove_tile(tile); } else if (tile.type.is_teleporter) { teleporter = tile; @@ -663,12 +657,10 @@ class Level { // 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 = this.cells[goal.y][goal.x]; + let tele_cell = goal.cell; current_cell._remove(actor); tele_cell._add(actor); current_cell = tele_cell; - actor.x = goal.x; - actor.y = goal.y; if (this.attempt_step(actor, actor.direction)) // Success, teleportation complete break; @@ -683,17 +675,56 @@ class Level { } } + // ------------------------------------------------------------------------- + // Undo handling + + commit() { + this.undo_stack.push(this.pending_undo); + this.pending_undo = []; + } + + 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]; + this.pending_undo.push(() => obj[key] = old_val); + obj[key] = val; + } + collect_chip() { - if (this.chips_remaining > 0) { + 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() { @@ -702,11 +733,55 @@ class Level { return d; } - // TODO make a set of primitives for actually altering the level that also - // record how to undo themselves + // Tile stuff in particular + + remove_tile(tile) { + let cell = tile.cell; + let layer = cell._remove(tile); + this.pending_undo.push(() => cell._add(tile, layer)); + } + + add_tile(tile, cell, layer = null) { + cell._add(tile, layer); + this.pending_undo.push(() => 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) { 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; + } } @@ -915,8 +990,8 @@ const GAME_UI_HTML = `
- - + +

Solution demo available

@@ -966,6 +1041,7 @@ class Game { // TODO obey level options; allow overriding this.viewport_size_x = 9; this.viewport_size_y = 9; + this.scale = 1; document.body.innerHTML = GAME_UI_HTML; this.container = document.body.querySelector('main'); @@ -1026,6 +1102,23 @@ class Game { }).open(); ev.target.blur(); }); + this.undo_button = this.container.querySelector('.controls .control-undo'); + this.undo_button.addEventListener('click', ev => { + let player_cell = this.level.player.cell; + while (player_cell === this.level.player.cell && this.level.undo_stack.length > 0) { + this.level.undo(); + } + if (this.level.undo_stack.length === 0) { + this.set_state('waiting'); + } + else { + // Be sure to undo any success or failure + this.set_state('playing'); + } + this.update_ui(); + this.redraw(); + ev.target.blur(); + }); // Demo playback this.container.querySelector('.demo .demo-step-1').addEventListener('click', ev => { this.advance_by(1); @@ -1050,8 +1143,8 @@ class Game { return; let rect = this.level_canvas.getBoundingClientRect(); - let x = Math.floor((ev.clientX - rect.x) / 2 / this.tileset.size_x + this.viewport_x); - let y = Math.floor((ev.clientY - rect.y) / 2 / this.tileset.size_y + this.viewport_y); + let x = Math.floor((ev.clientX - rect.x) / this.scale / this.tileset.size_x + this.viewport_x); + let y = Math.floor((ev.clientY - rect.y) / this.scale / this.tileset.size_y + this.viewport_y); this.level.move_to(this.level.player, x, y); }); @@ -1261,7 +1354,7 @@ class Game { if (! this._inventory_tiles[name]) { // TODO reuse the canvas let canvas = mk('canvas', {width: this.tileset.size_x, height: this.tileset.size_y}); - this.tileset.draw({type: TILE_TYPES[name]}, canvas.getContext('2d'), 0, 0); + this.tileset.draw({type: TILE_TYPES[name]}, null, canvas.getContext('2d'), 0, 0); this._inventory_tiles[name] = canvas.toDataURL(); } return this._inventory_tiles[name]; @@ -1348,29 +1441,34 @@ class Game { } redraw() { + // TODO split this out to a renderer, call it every frame, have the level flag itself as dirty let ctx = this.level_canvas.getContext('2d'); ctx.clearRect(0, 0, this.level_canvas.width, this.level_canvas.height); let xmargin = (this.viewport_size_x - 1) / 2; let ymargin = (this.viewport_size_y - 1) / 2; - let x0 = this.level.player.x - xmargin; - let y0 = this.level.player.y - ymargin; + let player_cell = this.level.player.cell; + let x0 = player_cell.x - xmargin; + let y0 = player_cell.y - ymargin; x0 = Math.max(0, Math.min(this.level.width - this.viewport_size_x, x0)); y0 = Math.max(0, Math.min(this.level.height - this.viewport_size_y, y0)); this.viewport_x = x0; this.viewport_y = y0; - for (let dx = 0; dx < this.viewport_size_x; dx++) { - for (let dy = 0; dy < this.viewport_size_y; dy++) { - let cell = this.level.cells[dy + y0][dx + x0]; - /* - if (! cell.is_dirty) - continue; - */ - cell.is_dirty = false; - - for (let tile of cell) { - if (! tile.doomed) { - this.tileset.draw(tile, ctx, dx, dy); + // Draw in layers, so animated objects aren't overdrawn by neighboring terrain + let any_drawn = true; + let i = -1; + while (any_drawn) { + i++; + any_drawn = false; + for (let dx = 0; dx < this.viewport_size_x; dx++) { + for (let dy = 0; dy < this.viewport_size_y; dy++) { + let cell = this.level.cells[dy + y0][dx + x0]; + let tile = cell[i]; + if (tile) { + any_drawn = true; + if (! tile.doomed) { + this.tileset.draw(tile, this.level, ctx, dx, dy); + } } } } @@ -1399,7 +1497,9 @@ class Game { if (scale <= 0) { scale = 1; } - // FIXME this doesn't take into account the inventory, which is also affected by scale + + // FIXME the above logic doesn't take into account the inventory, which is also affected by scale + this.scale = scale; this.container.style.setProperty('--scale', scale); } } diff --git a/js/tileset.js b/js/tileset.js index d7b21e7..5725bff 100644 --- a/js/tileset.js +++ b/js/tileset.js @@ -1,3 +1,5 @@ +import { DIRECTIONS } from './defs.js'; + // TODO really need to specify this format more concretely, whoof // XXX special kinds of drawing i know this has for a fact: // - letter tiles draw from a block of half-tiles onto the center of the base @@ -129,8 +131,10 @@ export const CC2_TILESET_LAYOUT = { base: 'purple_floor', overlay: [8, 9], }, - // TODO state (10 is closed) - trap: [9, 9], + trap: { + closed: [9, 9], + open: [10, 9], + }, button_gray: [11, 9], fireball: [[12, 9], [13, 9], [14, 9], [15, 9]], @@ -427,27 +431,35 @@ export class Tileset { dx * this.size_x, dy * this.size_y, w, h); } - draw(tile, ctx, x, y) { - this.draw_type(tile.type.name, tile, ctx, x, y); + draw(tile, level, ctx, x, y) { + this.draw_type(tile.type.name, tile, level, ctx, x, y); } // Draws a tile type, given by name. Passing in a tile is optional, but // without it you'll get defaults. - draw_type(name, tile, ctx, x, y) { + draw_type(name, tile, level, ctx, x, y) { let drawspec = this.layout[name]; if (! drawspec) { console.error(`Don't know how to draw tile type ${type.name}!`); return; } + /* + if (tile && tile.movement_cooldown) { + let offset = DIRECTIONS[tile.direction].movement; + x -= tile.movement_cooldown / tile.type.movement_speed * offset[0]; + y -= tile.movement_cooldown / tile.type.movement_speed * offset[1]; + } + */ + if (drawspec.overlay) { // Goofy overlay thing used for green/purple toggle tiles and // southeast thin walls. Draw the base (a type name), then draw // the overlay (either a type name or a regular draw spec). // TODO chance of infinite recursion here - this.draw_type(drawspec.base, tile, ctx, x, y); + this.draw_type(drawspec.base, tile, level, ctx, x, y); if (typeof drawspec.overlay === 'string') { - this.draw_type(drawspec.overlay, tile, ctx, x, y); + this.draw_type(drawspec.overlay, tile, level, ctx, x, y); return; } else { @@ -464,11 +476,26 @@ export class Tileset { // Unwrap animations etc. if (!(coords instanceof Array)) { - // Must be an object of directions - coords = coords[(tile && tile.direction) ?? 'south']; + // Must be an object of either tile-specific state, or directions + if (name === 'trap') { + if (tile && tile.open) { + coords = coords.open; + } + else { + coords = coords.closed; + } + } + else { + coords = coords[(tile && tile.direction) ?? 'south']; + } } if (coords[0] instanceof Array) { - coords = coords[0]; + if (level) { + coords = coords[Math.floor(level.tic_counter % 5 / 5 * coords.length)]; + } + else { + coords = coords[0]; + } } if (drawspec.mask) { diff --git a/js/tiletypes.js b/js/tiletypes.js index 29ab723..a6fd202 100644 --- a/js/tiletypes.js +++ b/js/tiletypes.js @@ -19,12 +19,14 @@ const TILE_TYPES = { wall_appearing: { blocks: true, on_bump(me, level, other) { - me.become('wall'); + level.transmute_tile(me, 'wall'); }, }, popwall: { + blocks_monsters: true, + blocks_blocks: true, on_depart(me, level, other) { - me.become('wall'); + level.transmute_tile(me, 'wall'); }, }, thinwall_n: { @@ -45,13 +47,13 @@ const TILE_TYPES = { fake_wall: { blocks: true, on_bump(me, level, other) { - me.become('wall'); + level.transmute_tile(me, 'wall'); }, }, fake_floor: { blocks: true, on_bump(me, level, other) { - me.become('floor'); + level.transmute_tile(me, 'floor'); }, }, @@ -110,7 +112,7 @@ const TILE_TYPES = { blocks_blocks: true, // TODO block melinda only without the hiking boots; can't use ignore because then she wouldn't step on it :S also ignore doesn't apply to blocks anyway. on_arrive(me, level, other) { - me.become('floor'); + level.transmute_tile(me, 'floor'); }, }, gravel: { @@ -122,10 +124,10 @@ const TILE_TYPES = { on_arrive(me, level, other) { if (other.type.is_player) { level.fail("Oops! You can't walk on fire without fire boots!"); - other.become('player_burned'); + level.transmute_tile(other, 'player_burned'); } else { - other.destroy(); + level.remove_tile(other); } }, }, @@ -133,15 +135,15 @@ const TILE_TYPES = { on_arrive(me, level, other) { // TODO cc1 allows items under water, i think; water was on the upper layer if (other.type.name == 'dirt_block' || other.type.name == 'clone_block') { - other.destroy(); - me.become('dirt'); + level.remove_tile(other); + level.transmute_tile(me, 'dirt'); } else if (other.type.is_player) { - level.fail("Oops! You can't swim without flippers!"); - other.become('player_drowned'); + level.fail("swimming with the fishes"); + level.transmute_tile(other, 'player_drowned'); } else { - other.destroy(); + level.remove_tile(other); } }, }, @@ -234,8 +236,11 @@ const TILE_TYPES = { bomb: { // TODO explode on_arrive(me, level, other) { - me.destroy(); - other.destroy(); + level.remove_tile(me); + level.remove_tile(other); + if (other.type.is_player) { + level.fail("watch where you step"); + } }, }, thief_tools: { @@ -267,6 +272,7 @@ const TILE_TYPES = { dirt_block: { blocks: true, is_object: true, + is_actor: true, is_block: true, ignores: new Set(['fire']), }, @@ -274,6 +280,7 @@ const TILE_TYPES = { // TODO is this in any way distinct from dirt block blocks: true, is_object: true, + is_actor: true, is_block: true, ignores: new Set(['fire']), }, @@ -284,10 +291,9 @@ const TILE_TYPES = { cloner: { blocks: true, activate(me, level) { - let cell = level.cells[me.y][me.x]; - // Clone so we don't end up repeatedly cloning the same object - let current_tiles = Array.from(cell); - for (let tile of current_tiles) { + let cell = me.cell; + // Copy, so we don't end up repeatedly cloning the same object + for (let tile of Array.from(cell)) { if (tile !== me && tile.type.is_actor) { // Copy this stuff in case the movement changes it let type = tile.type; @@ -295,16 +301,14 @@ const TILE_TYPES = { // Unstick and try to move the actor; if it's blocked, // abort the clone - tile.stuck = false; + level.set_actor_stuck(tile, false); if (level.attempt_step(tile, direction)) { level.actors.push(tile); - // FIXME rearrange to make this possible lol - // FIXME go through level for this, and everything else of course // FIXME add this underneath, just above the cloner - cell._add(new tile.constructor(type, me.x, me.y, direction)); + level.add_tile(new tile.constructor(type, direction), cell); } else { - tile.stuck = true; + level.set_actor_stuck(tile, true); } } } @@ -313,7 +317,7 @@ const TILE_TYPES = { trap: { on_arrive(me, level, other) { if (! me.open) { - other.stuck = true; + level.set_actor_stuck(other, true); } }, }, @@ -331,7 +335,7 @@ const TILE_TYPES = { for (let actor of level.actors) { // TODO generify somehow?? if (actor.type.name === 'tank_blue') { - actor.direction = DIRECTIONS[actor.direction].opposite; + level.set_actor_direction(actor, DIRECTIONS[actor.direction].opposite); } } }, @@ -339,20 +343,21 @@ const TILE_TYPES = { button_green: { on_arrive(me, level, other) { // Swap green floors and walls + // TODO could probably make this more compact for undo purposes for (let row of level.cells) { for (let cell of row) { for (let tile of cell) { if (tile.type.name === 'green_floor') { - tile.become('green_wall'); + level.transmute_tile(tile, 'green_wall'); } else if (tile.type.name === 'green_wall') { - tile.become('green_floor'); + level.transmute_tile(tile, 'green_floor'); } else if (tile.type.name === 'green_chip') { - tile.become('green_bomb'); + level.transmute_tile(tile, 'green_bomb'); } else if (tile.type.name === 'green_bomb') { - tile.become('green_chip'); + level.transmute_tile(tile, 'green_chip'); } } } @@ -366,9 +371,9 @@ const TILE_TYPES = { if (me.connection && ! me.connection.doomed) { let trap = me.connection; trap.open = true; - for (let tile of level.cells[trap.y][trap.x]) { + for (let tile of trap.cell) { if (tile.stuck) { - tile.stuck = false; + level.set_actor_stuck(tile, false); } } } @@ -377,9 +382,9 @@ const TILE_TYPES = { if (me.connection && ! me.connection.doomed) { let trap = me.connection; trap.open = false; - for (let tile of level.cells[trap.y][trap.x]) { + for (let tile of trap.cell) { if (tile.is_actor) { - tile.stuck = false; + level.set_actor_stuck(tile, true); } } } @@ -553,7 +558,7 @@ const TILE_TYPES = { on_arrive(me, level, other) { if (other.type.is_player) { level.collect_chip(); - me.destroy(); + level.remove_tile(me); } }, },