Home Reference Source Repository

app/dungeons/Dungeon.js

import Observable from '../util/Observable.js';
import Tile from '../tiles/Tile.js';
import Creature from '../entities/creatures/Creature.js';
import Move from '../entities/creatures/moves/Move.js';
import Moves from '../entities/creatures/moves/Moves.js';
import GameConditions from '../conditions/GameConditions.js';

import PlayableCharacter from '../entities/creatures/PlayableCharacter.js';

import GameEvents from '../events/GameEvents.js';
import HumanToMoveEvent from '../events/HumanToMoveEvent.js';
import HumanMovingEvent from '../events/HumanMovingEvent.js';

import DebugConsole from '../util/DebugConsole.js';

/**
 * An explorable dungeon in the game. Contains a grid of tiles
 * and the high-level logic for advancing the game state. Optionally
 * contains a set of victory and defeat conditions
 */
export default class Dungeon extends Observable {
    /**
     * Initializes an empty dungeon with the specified width
     * @param {number} width - The number of columns in the dungeon
     * @param {number} height - The number of rows in the dungeon
     */
    constructor(width, height) {
        super(width, height);
        if(isNaN(width) || isNaN(height)) {
            throw new Error('`width` and `height` must be numbers');
        }

        this._width = +width;
        this._height = +height;
        const grid = this._grid = [];
        this._creatureMap = new WeakMap(); // TODO: Will this cause problems?
        for(let x = 0; x < width; x++) {
            let col = grid[x] = [];
            for(let y = 0; y < height; y++) {
                col[y] = new Tile(x, y);
            }
        }
        this._timestep = 0;
    }

    /**
     * @return {number} - The number of columns in the dungeon
     */
    getWidth() {
        return this._width;
    }

    /**
     * @return {number} - The number of rows in the dungeon
     */
    getHeight() {
        return this._height;
    }

    /**
     * @return {number} - The number of timesteps that have occurred
     */
    getCurrentTimestep() {
        return this._timestep;
    }

    /**
     * Gets an RNG for use with random effects. All creatures behaving
     * randomly should use this RNG.
     * @return {Random} - An RNG for random effects within the dungeon
     */
    getRng() {
        let rng = this._rng;
        if(!rng) {
            rng = this._rng = Random.engines.mt19937();
            rng.seed(this._seed);
        }
        return rng;
    }

    /**
     * Changes the tile at the specified position
     * @param {Tile} tile - The new tile
     * @param {number} x - The x coordinate
     * @param {number} y - The y coordinate
     */
    setTile(tile, x, y) {
        if(!tile instanceof Tile) {
            throw new Error('First parameter must be a tile');
        } else if(isNaN(x) || isNaN(y)) {
            throw new Error('Must pass an x and y coordinate');
        }
        this._grid[x][y] = tile;
    }

    /**
     * Gets a tile from coordinates or a creature
     * @param {Creature|number} param1
     * @param {number} [param2]
     */
    getTile(param1, param2) {
        if(param1 instanceof Creature) {
            return this._creatureMap.get(param1);
        } else if(Number.isInteger(+param1) && Number.isInteger(+param2)) {
            const col = this._grid[+param1];
            return col && col[+param2];
        } else {
            throw new Error('Must pass a Creature or XY coordinates');
        }
    }

    /**
     * Gets a 1-D array of the tiles in the map, optionally filtered
     * @param {function} filter - An `Array.filter` predicate. The first parameter will be a Tile
     */
    getTiles(filter) {
        return this._grid.reduce(function(prev, col) {
            Array.prototype.push.apply(prev, filter ? col.filter(filter) : col);
            return prev;
        }, []);
    }

    /**
     * Tells whether the dungeon is traversable.
     * @param {function} [isTraversable] - Optional predicate
     * for determining if a tile counts as traversable. Defaults
     * to testing if tile has a floor and isn't solid
     */
    isConnected(isTraversable = (tile) => tile.hasFloor() && !tile.isSolid()) {
        const key = (tile) => `${tile.getX()},${tile.getY()}`;
        const traversableTiles = this.getTiles(isTraversable);
        const unvisitedSet = {};
        const visitedSet = {};
        const traversalList = [];
        traversableTiles.forEach(function(tile) {
            unvisitedSet[key(tile)] = tile;
        });
        let tile = traversableTiles[0];
        traversalList.push(tile);
        delete unvisitedSet[key(tile)];
        while(traversalList.length > 0) {
            tile = traversalList.pop();
            tile.getNeighbors8(this).forEach(function(tile) {
                const tileKey = key(tile);
                if(unvisitedSet[tileKey] && !visitedSet[tileKey]) {
                    traversalList.push(tile);
                    delete unvisitedSet[tileKey];
                    visitedSet[tileKey] = tile;
                }
            });
        }
        return Object.keys(unvisitedSet).length === 0;
    }

    /**
     * Performs an operation with each tile in the map
     * @param {function} func - A function that will be called for each tile.
     * The first param is the Tile. The second is the x-coordinate; the third, the y-coordinate
     */
    forEachTile(func) {
        const grid = this._grid;
        const width = this.getWidth();
        const height = this.getHeight();
        for(let x = 0; x < width; x++) {
            let col = grid[x];
            for(let y = 0; y < height; y++) {
                func(col[y], x, y);
            }
        }
    }

    /**
     * Positions a creature within the Dungeon. If the creature is already present,
     * it will be removed from it's previous location first
     * @param {Creature} creature - The creature to add or reposition
     * @param {number} x - The x-coordinate
     * @param {number} y - The y-coordinate
     */
    moveCreature(creature, x, y) {
        if(!(creature instanceof Creature)) {
            throw new Error('First parameter must be a creature: ' + creature);
        } else if(!Number.isInteger(x) || !Number.isInteger(y)) {
            throw new Error('Second and third parameters must be integers');
        } else if(this.getTile(x, y).getCreature(x, y)) {
            throw new Error('Destination already occupied');
        }
        const existed = !!this._creatureMap.get(creature);
        if(existed) {
            this.removeCreature(creature);
        }
        const tile = this._grid[x][y];
        tile.setCreature(creature);
        this._creatureMap.set(creature, tile);
        if(creature instanceof PlayableCharacter) {
            this._player = creature;
            creature._updateVisionMap(this); // TODO: Figure out a way for player to know to update itself
        }
        this.fireEvent(new GameEvents.PositionChangeEvent(this, creature, x, y));
        if(!existed) {
            this.fireEvent(new GameEvents.SpawnEvent(this, creature, x, y));
        }
        this._notifyObservers();
    }

    /**
     * @return {Array<Creature>} - The creatures in the dungeon
     */
    getCreatures() {
        const creatures = [];
        this.forEachTile(function(tile){
            const creature = tile.getCreature();
            if(creature) {
                creatures.push(creature);
            }
        });
        return creatures;
    }

    /**
     * @return {PlayableCharacter} - The player's character, or null if none exists.
     */
    getPlayableCharacter() {
        return this._player || null;
    }

    /**
     * Tells if the game has ended through either defeat or victory
     * @return {boolean} - `true` if the player has won or lost; `false` otherwise
     */
    hasEnded() {
        const conditions = this._gameConditions;
        if(conditions) {
            return conditions.hasPlayerWon(this) || conditions.hasPlayerLost(this);
        } else {
            return false;
        }
    }

    /**
     * Removes a creature from the dungeon, either by using
     * a reference to the creature, or coordinates
     * @param {Creature|number} param1
     * @param {number} [param2]
     */
    removeCreature(param1, param2) {
        if(param1 instanceof Creature) {
            this._creatureMap.get(param1).removeCreature();
        } else if(isNaN(param1) || isNaN(param2)) {
            throw new Error('Must pass one Creature or two numbers');
        } else {
            this._grid[param1][param2].removeCreature();
        }
        this._notifyObservers();
    }

    /**
     * Dispatches a {@link GameEvent} to all observers
     * @param {GameEvent} event - An event representing something
     * that happened in the Dungeon
     */
    fireEvent(event) {
        // TODO: Should this be a separate subscriber list?
        this._notifyObservers(event);
    }

    /**
     * Sets the conditions for victory and defeat in the Dungeon
     * @param {GameConditions} conditions
     */
    setGameConditions(conditions) {
        if(!(conditions instanceof GameConditions)) {
            throw new Error('First parameter must be a GameConditions');
        }
        this._gameConditions = conditions;
    }

    /**
     * Tells if the dungeon's state can advance.
     * @return {boolean} - `false` if the game has ended or if the game is blocked
     * waiting for the player's move; `true` otherwise.
     */
    canAdvance() {
        if(this.hasEnded()) {
            return false;
        } else {
            // Game cannot advance past player's turn until
            // a move is queued
            const activeCreature = this.getActiveCreature();
            if(activeCreature instanceof PlayableCharacter) {
                return activeCreature.hasMoveQueued();
            } else {
                return true;
            }
        }
    }

    /**
     * Advances the game state until the player's next move or the
     * game is over. This contains the game loop.
     */
    resolveUntilBlocked() {
        function time() {
            return window.performance ? window.performance.now() : Date.now();
        }

        const start = time();

        while(this.canAdvance()) {
            this.resolveNextStep();
        }
        const activeCreature = this.getActiveCreature();
        if(activeCreature instanceof PlayableCharacter) {
            this.fireEvent(new HumanToMoveEvent(this, activeCreature));
        }

        const delta = time() - start;
        DebugConsole.log(`Timestep: ${delta.toFixed(2)}ms`);
    }

    /**
     * Gets the creature whose turn it is to move. This will
     * be a creature whose speed counter has reached 0. Ties are broken
     * with the creature's base speed.
     * @return {Creature} - The creature that can move next.
     */
    getActiveCreature() {
        const creatures = this.getCreatures();
        return creatures.filter(function(creature) {
            return creature.canActThisTimestep();
        }).sort(function(c1, c2) {
            return c1.getSpeed() < c2.getSpeed();
        })[0];
    }

    /**
     * Advances the dungeon by one timestep. During this time, multiple
     * creatures can move (if their speed counters have reached 0).
     */
    resolveNextStep() {
        const self = this;
        if(this.hasEnded()) {
            throw new Error('Dungeon has ended. No more steps allowed');
        }

        const activeCreature = this.getActiveCreature();

        if(activeCreature) {
            if(activeCreature instanceof PlayableCharacter) {
                this.fireEvent(new HumanToMoveEvent(this, activeCreature));
            }
            const move = activeCreature.getNextMove(this);
            if(!(move instanceof Move)) {
                throw new Error('Expected move from ' + activeCreature + ', got ' + move);
            }
            if(activeCreature instanceof PlayableCharacter) {
                this.fireEvent(new HumanMovingEvent(this, activeCreature));
            }

            try {
                const dungeon = this;
                activeCreature.executeMove(this, move);
                this.getCreatures().forEach(function(creature) {
                    if(activeCreature !== creature && move.isSeenBy(dungeon, creature)) {
                        creature.observeMove(dungeon, activeCreature, move);
                    }
                });
            } catch(error) {
                console.error(error);
                activeCreature.executeMove(this, new Moves.WaitMove(this.getTile(activeCreature)));
                //activeCreature.wait();
            }
        } else {
            this._timestep++;
            this.getCreatures().forEach(function(creature) {
                creature.timestep(self);
            });
        }

        // Check for deaths
        this.getCreatures().forEach(function(creature) {
            if(creature.getCurrentHP() <= 0) {
                creature.die(self);
            } else if(!creature.isFlying() && !self.getTile(creature).hasFloor()) {
                creature.die(self);
            }
        });

        const conditions = this._gameConditions;
        if(conditions) {
            if(conditions.hasPlayerWon(this)) {
                this.fireEvent(new GameEvents.VictoryEvent(this));
            } else if(conditions.hasPlayerLost(this)) {
                this.fireEvent(new GameEvents.DefeatEvent(this));

            }
        }
    }
}