Split out the game proper
This commit is contained in:
parent
1a9c3f619d
commit
549b34ad30
864
js/game.js
Normal file
864
js/game.js
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
867
js/main.js
867
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) {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user