Home Reference Source Repository

app/views/GraphicalDungeonView.js

import Dungeon from '../dungeons/Dungeon.js';
import GameEvent from '../events/GameEvent.js';
import GameEvents from '../events/GameEvents.js';

import GridAnimations from './GridAnimations.js';
import DungeonTooltips from './DungeonTooltips.js';

import DamageTypes from '../entities/DamageTypes.js';

import GraphicalViewSharedData from '../controllers/GraphicalViewSharedData.js';

const DAMAGE_COLORS = {
    [DamageTypes.MELEE_PHYSICAL]: 'darkred',
    [DamageTypes.RANGED_PHYSICAL]: 'darkred',
    [DamageTypes.FIRE]: 'orange',
    [DamageTypes.COLD]: 'darkblue',
    [DamageTypes.ELECTRICAL]: 'yellow',
    [DamageTypes.ENERGY]: 'white',
    [DamageTypes.POISON]: 'emerald'
};

const DAMAGE_OUTLINE_COLORS = {
    [DamageTypes.MELEE_PHYSICAL]: 'pink',
    [DamageTypes.RANGED_PHYSICAL]: 'pink',
    [DamageTypes.FIRE]: 'darkred',
    [DamageTypes.COLD]: 'skyblue',
    [DamageTypes.ELECTRICAL]: 'orange',
    [DamageTypes.ENERGY]: 'yellow',
    [DamageTypes.POISON]: 'darkgreen'
};

//const ANIMATE_BARS = false;

function getGridTileFast(grid, x, y) {
    return grid.children[0].children[y].children[x];
}

function updateRangeIndicator(grid, dungeon, attack) {
    $('.range-indicator').remove();
    if(!attack) {
        return;
    }
    let color = 'rgb(70, 70, 90)';
    if(typeof attack.isMovementAbility === 'function') {
        color = 'darkviolet';
    } else if(typeof attack.isTargetted === 'function') {
        color = 'violet';
    }
    const player = dungeon.getPlayableCharacter();
    const playerTile = dungeon.getTile(player);
    const playerX = playerTile.getX();
    const playerY = playerTile.getY();
    const range = attack.getRange();
    const dimension = range * 2 + 3;
    const rangeArray = new Array(dimension).fill(0).map(function(unused, x) {
        let dx = x - range - 1;
        return new Array(dimension).fill(0).map(function(unused, y) {
            let dy = y - range - 1;
            return dx * dx + dy * dy <= range * range;
        });
    });
    $('<div class="range-indicator">').css({
        zIndex: 5,
        position: 'absolute',
        pointerEvents: 'none',
        left: (playerX - range - 1) * 5 + 'em',
        top: (playerY - range - 1) * 5 + 'em'
    }).append(rangeArray.map(function(row, dy) {
        return $('<div class="range-indicator-row">').css({
            width: dimension * 5 + 'em',
            overflow: 'auto'
        }).append(row.map(function(isTargettable, dx) {
            return $('<div class="range-indicator-cell">').css({
                float: 'left',
                width: '5em',
                height: '5em',
                pointerEvents: 'none',
                borderStyle: 'solid',
                borderColor: color,
                borderRadius: '-8px',
                borderTopWidth: (isTargettable && !rangeArray[dy - 1][dx]) ? '2px' : '0',
                borderRightWidth: (isTargettable && !rangeArray[dy][dx + 1]) ? '2px' : '0',
                borderBottomWidth: (isTargettable && !rangeArray[dy + 1][dx]) ? '2px' : '0',
                borderLeftWidth: (isTargettable && !rangeArray[dy][dx - 1]) ? '2px' : '0'
            });
        }));
    })).appendTo(grid);
}

const getScrollingText = (function() {
    const previousOccurences = {};

    function markAndCountRecentOccurences(x, y, delta) {
        //debugger;
        const now = Date.now();
        const key  = `${x},${y}`;
        let array = previousOccurences[key] || (previousOccurences[key] = []);
        array = previousOccurences[key] = array.filter((time) => now - time < delta);
        array.push(now);
        return array.length - 1;
    }

    return function getScrollingText(text, x, y, color, outlineColor) {
        let startY = (y * 5) + 2.5 + 'rem';
        let endY = (y * 5) + 'rem';
        let size = text.toString().length <= 2 ? '2em' : '1.5em';
        let previousOccurenceCount = markAndCountRecentOccurences(x, y, 1000);
        return $('<div>').text(text)
            .css({
                color,
                fontWeight: 'bold',
                zIndex: 3,
                pointerEvents: 'none',
                fontSize: size,
                webkitTextStroke: `.025em ${outlineColor}`,
                position: 'absolute',
                width: '5rem',
                textAlign: 'center',
                top: startY,
                left: (x * 5) + - 1 + 2 * previousOccurenceCount + 'rem'
            })
            .animate({
                top: endY,
                opacity: 0
            }, 1000, function() {
                $(this).remove();
            });
    };
}());

export default class GraphicalDungeonView {
    constructor(sharedData) {
        if(!(sharedData instanceof GraphicalViewSharedData)) {
            throw new Error('First parameter must be a GraphicalViewSharedData');
        }
        const self = this;
        this._sharedData = sharedData;

        this._creatureDoms = {};
        this._itemDoms = {};

        const scrollPane = this._scrollPane = document.createElement('div');

        function buildDom() {
            const dungeon = sharedData.getDungeon();
            const width = dungeon.getWidth();
            const height = dungeon.getHeight();

            scrollPane.innerHTML = '';
            scrollPane.classList.add('grid-scroll');
            const grid = self._grid = document.createElement('div');
            grid.classList.add('grid');
            grid.classList.add('theme-default');
            grid.setAttribute('role', 'grid');
            grid.setAttribute('aria-readonly', true);
            scrollPane.appendChild(grid);
            for(let y = 0; y < height; y++) {
                const row = document.createElement('div');
                row.classList.add('row');
                row.setAttribute('role', 'row');
                grid.appendChild(row);
                for(let x = 0; x < width; x++) {
                    const cell = document.createElement('div');
                    cell.classList.add('cell');
                    cell.setAttribute('data-x', x);
                    cell.setAttribute('data-y', y);
                    cell.setAttribute('role', 'gridcell');
                    row.appendChild(cell);
                }
            }
            self._tileDimension = grid.firstChild.firstChild.clientHeight;
            self._halfTileDimension = self._tileDimension >> 1;
            self._synchronizeView();
        }

        sharedData.addObserver(function observer(event) {
            if(event instanceof Dungeon){
                buildDom();
                setTimeout(function() {
                    self.scroll();
                    document.querySelector('section.game').focus();
                });
            } else {
                self.update(event);
            }
        });

        (function() {
            let timer;
            sharedData.addObserver(function() {
                clearTimeout(timer);
                timer = setTimeout(function() {
                    const dungeon = sharedData.getDungeon();
                    let targettable;
                    if(typeof sharedData.getTargettedAbility() === 'number') {
                        targettable = dungeon.getPlayableCharacter().getAbility(sharedData.getTargettedAbility());
                    } else if(typeof sharedData.getTargettedItem() === 'number') {
                        targettable = dungeon.getPlayableCharacter().getInventory().getItem(sharedData.getTargettedItem());
                    } else {
                        targettable = dungeon.getPlayableCharacter().getRangedWeapon();
                    }
                    updateRangeIndicator(self.getDom().children[0], sharedData.getDungeon(), targettable);

                    Array.from(document.querySelectorAll('[data-keyboard-move]')).forEach((element)=>element.removeAttribute('data-keyboard-move'));
                    const attackTarget = sharedData.getAttackTarget();
                    const abilityTarget = sharedData.getAbilityTarget();
                    const itemTarget = sharedData.getItemTarget();
                    const target = attackTarget || abilityTarget || itemTarget;
                    if(target) {
                        const moveName = (attackTarget && 'AttackMove') ||
                            (abilityTarget && 'UseAbilityMove') ||
                            (itemTarget && 'UseItemMove');
                        self.getDom().querySelector(`.cell[data-x="${target.getX()}"][data-y="${target.getY()}"]`)
                            .setAttribute('data-keyboard-move', moveName);
                    }
                });
            });
        })();

        buildDom();
        DungeonTooltips.bindTooltips(sharedData, self.getDom());
        this.scroll();
    }

    getDom() {
        return this._scrollPane;
    }

    /**
     * Makes the view match the current game state.
     * This is not called every event. It is called
     * when skipping animations (e.g because player
     * provided multiple inputs quickly)
     */
    _synchronizeView() {
        const self = this;
        const grid = this.getDom();
        const dungeon = this._sharedData.getDungeon();
        const player = dungeon.getPlayableCharacter();

        this._resetAnimationQueue();

        dungeon.forEachTile(function(tile, x, y) {
            const cell = getGridTileFast(grid, x, y);
            let fc;
            while(fc = cell.firstChild) {
                cell.removeChild(fc);
            }
            cell.setAttribute('data-tile-type', tile.constructor.name);
            cell.setAttribute('data-room-key', tile.getRoomKey());
            if(player) {
                cell.setAttribute('data-explored', player.hasSeen(tile));
                cell.setAttribute('data-visible', player.canSee(dungeon, tile));
                const creature = tile.getCreature();
                if(creature) {
                    const dom = self._getDomForCreature(creature);
                    cell.appendChild(dom);
                    self._animateBars(creature);
                }
            }
            tile.getItems().forEach(function(item) {
                cell.appendChild(self._getDomForItem(item));
            });
        });

        // Set grid width programatically to override table layout algorithm
        const table = grid.children[0];
        table.style.width = 5 * dungeon.getWidth() + 'em';
    }

    _animateBars(creature) {
        //const SCALE = 2;
        if(creature.isDead()) {
            return;
        }
        const dom = this._getDomForCreature(creature);
        dom.querySelector('.hp').style.width = creature.getCurrentHP() * 100 / creature.getBaseHP() + '%';
        dom.querySelector('.action-bar').style.width = creature.getTimeToNextMove() * 100 / creature.getSpeed() + '%';
    }

    _resetAnimationQueue() {
        if(this._rejections) {
            this._rejections.forEach(clearTimeout);
        }
        this._rejections = [];
    }

    _createDelay(action, delay) {
        this._rejections.push(setTimeout(action, delay));
    }

    _queueAnimation(event) {
        const self = this;
        const grid = this.getDom();
        const dungeon = this._sharedData.getDungeon();
        const delay = event.getTimestamp() - (this._lastHumanMovingEvent ? this._lastHumanMovingEvent.getTimestamp() : 0);
        if(event instanceof GameEvents.AbilityEvent) {
            const ability = event.getAbility();
            if(ability.getRange() > 1 && ability.isTargetted() && ability.isTargetCreature() && !ability.isMovementAbility()) {
                const caster = event.getCreature();
                const casterLocation = dungeon.getTile(caster);
                const target = event.getTile().getCreature();
                this._createDelay(function() {
                    let targetTile = (target && dungeon.getTile(target)) || event.getTile(); // Get target position dynamically so shooting at moving targets looks ok
                    GridAnimations.animateProjectile(dungeon, grid, ability, casterLocation, targetTile);
                }, delay);
            }
        } else if(event instanceof GameEvents.AttackEvent) {
            let attacker = event.getAttacker();
            let target = event.getTarget();
            let weapon = event.getWeapon();
            let tile = dungeon.getTile(attacker);
            let cell = getGridTileFast(grid, tile.getX(), tile.getY());
            this._createDelay(function() {
                let targetTile = dungeon.getTile(target); // Get target position dynamically so shooting at moving targets looks ok
                if(weapon.getRange() === 1) {
                    // TODO: Better animation
                    cell.setAttribute('data-event-name', 'AttackEvent');
                } else {
                    GridAnimations.animateProjectile(dungeon, grid, weapon, tile, targetTile);
                }
            }, delay);
        } else if(event instanceof GameEvents.MoveEvent || event instanceof GameEvents.PositionChangeEvent || event instanceof GameEvents.SpawnEvent) {
            let player = dungeon.getPlayableCharacter();
            if(player) {
                let playerLocation = dungeon.getTile(player);
                let visionRadius = Math.ceil(player.getVisionRadius());
                this._createDelay(function() {
                    // TODO: Refactor this? Perhaps all positioning requires a common, secondary event
                    let to = event.getX ? {x: event.getX(), y: event.getY()} : event.getToCoords();
                    let cell = getGridTileFast(grid, to.x, to.y);
                    let creature = event.getCreature();
                    let dom = self._getDomForCreature(creature);
                    cell.appendChild(dom);

                    // TODO: Can this move inside the if?
                    // Update player vision
                    Array.from(grid.querySelectorAll('[data-visible="true"]')).forEach(function(cell) {
                        cell.setAttribute('data-visible', 'false');
                    });
                    let startX = Math.max(0, playerLocation.getX() - visionRadius);
                    let endX = Math.min(dungeon.getWidth() - 1, playerLocation.getX() + visionRadius);
                    let startY = Math.max(0, playerLocation.getY() - visionRadius);
                    let endY = Math.min(dungeon.getHeight() - 1, playerLocation.getY() + visionRadius);
                    for(let x = startX; x <= endX; x++) {
                        for(let y = startY; y <= endY; y++) {
                            let tile = dungeon.getTile(x, y);
                            let cell = getGridTileFast(grid, x, y);
                            cell.setAttribute('data-tile-type', tile.constructor.name);
                            cell.setAttribute('data-explored', player.hasSeen(tile));
                            cell.setAttribute('data-visible', player.canSee(dungeon, tile));
                            tile.getItems().forEach(function(item) {
                                cell.appendChild(self._getDomForItem(item));
                            });
                        }
                    }

                    if(creature === player) {
                        self.scroll();
                    }

                }, delay);
            }
        } else if(event instanceof GameEvents.TakeItemEvent) {
            this._createDelay(function() {
                let location = event.getTile();
                let cell = getGridTileFast(grid, location.getX(), location.getY());
                cell.removeChild(self._getDomForItem(event.getItem()));
            }, delay);
        } else if(event instanceof GameEvents.HitpointsEvent) {
            let creature = event.getCreature();
            let tile = dungeon.getTile(creature);
            let x = tile.getX();
            let y = tile.getY();
            let cell = getGridTileFast(grid, x, y);
            this._createDelay(function() {
                cell.setAttribute('data-event-name', 'HitpointsEvent');
                cell.setAttribute('data-is-hp-change-negative', event.getAmount() < 0);
                let dom = self._getDomForCreature(creature);
                if(creature.isDead()) {
                    dom.setAttribute('data-is-dead', true);
                }
                if(event.getAmount() < 0) {
                    getScrollingText(event.getAmount(), x, y, DAMAGE_COLORS[event.getDamageType()] || 'green', DAMAGE_OUTLINE_COLORS[event.getDamageType()] || 'green')
                        .appendTo(grid.children[0]);
                }
            }, delay + 200); // Death needs to be delayed so it appears to follow its cause
        } else if(event instanceof GameEvents.ZeroDamageEvent) {
            let creature = event.getCreature();
            let type = event.getDamageType();
            let tile = dungeon.getTile(creature);
            let x = tile.getX();
            let y = tile.getY();
            let message = creature.getDamageReduction(type) === Infinity ? 'Immune' : 'Blocked';
            getScrollingText(message, x, y, 'white', 'black')
                .appendTo(grid.children[0]);
        } else if(event instanceof GameEvents.BuffAppliedEvent || event instanceof GameEvents.BuffEndedEvent) {
            this._createDelay(function() {
                const creature = event.getCreature();
                const dom = self._getDomForCreature(creature);
                dom.setAttribute('buffs', creature.getBuffs().map((buff)=>buff.toString()).join(' '));
            }, delay);
        }

        this._createDelay(function() {
            GridAnimations.animateEvent(dungeon, self, event);
        });
    }

    update(event) {
        const self = this;
        const grid = this.getDom();
        const dungeon = this._sharedData.getDungeon();

        if(event instanceof GameEvent) {
            if(event instanceof GameEvents.HumanMovingEvent) {
                Array.from(grid.querySelectorAll('[data-event-name]')).forEach(function(tile) {
                    tile.removeAttribute('data-event-name');
                });
                //this._synchronizeView();
                this._lastHumanMovingEvent = event;
            } else {
                this._queueAnimation(event);
            }
        }

        if(event instanceof GameEvents.HumanToMoveEvent || event instanceof GameEvents.HitpointsEvent) {
            dungeon.getCreatures().forEach(function(creature) {
                // TODO: Animate HP by moving this to AttackEvent handling
                self._animateBars(creature);
            });
        }
        
        Array.from(grid.querySelectorAll('[data-tile-type="DoorTile"]')).forEach(function(tile) {
            const { x, y } = tile.dataset;
            tile.dataset.doorOpen = dungeon.getTile(x, y).isOpen();
        });

        // Tempory
        // Sync phone charge state
        dungeon.getCreatures().filter(function(creature) {
            return creature.constructor.name === 'ClunkyNinetiesCellPhone';
        }).forEach(function(creature) {
            self._getDomForCreature(creature).setAttribute('data-phone-charged', creature.getRangedWeapon().isCharged(dungeon));
        });

        // TODO: Consider if visibility needs to be animated
        // during events other than HumanMovingEvent
        // Should only be an issue in destructible environment
    }

    scroll() {
        const grid = this.getDom();
        const dungeon = this._sharedData.getDungeon();
        const player = dungeon.getPlayableCharacter();
        const tile = dungeon.getTile(player);

        const cellDimension = this._tileDimension;
        const halfDimension = this._halfTileDimension;
        const cellOffsetX = tile.getX() * cellDimension + halfDimension;
        const cellOffsetY = tile.getY() * cellDimension + halfDimension;

        grid.scrollTop = cellOffsetY - grid.clientHeight / 2;
        grid.scrollLeft = cellOffsetX - grid.clientWidth / 2;
    }

    getSelectedTileCoordinates() {

    }

    _getDomForCreature(creature) {
        const id = creature.getId();
        const node = this._creatureDoms[id];
        if(node) {
            return node;
        } else {
            const div = document.createElement('div');
            div.setAttribute('data-id', id);
            div.setAttribute('data-creature-name', creature.toString());
            div.classList.add('entity');
            div.classList.add('creature');

            const stats = document.createElement('div');
            stats.classList.add('stats');
            div.appendChild(stats);

            const hp = document.createElement('div');
            hp.classList.add('hp');
            stats.appendChild(hp);

            const actionBar = document.createElement('div');
            actionBar.classList.add('action-bar');
            stats.appendChild(actionBar);

            return this._creatureDoms[id] = div;
        }
    }

    _getDomForItem(item) {
        const id = item.getId();
        const node = this._itemDoms[id];
        if(node) {
            return node;
        } else {
            const div = document.createElement('div');
            div.setAttribute('data-id', id);
            div.setAttribute('data-item-name', item.toString());
            div.classList.add('entity');
            div.classList.add('item');
            div.classList.add('icon');

            return this._itemDoms[id] = div;
        }
    }
}