puzzlescript
Version:
Play PuzzleScript games in your terminal!
1,027 lines • 42.6 kB
JavaScript
"use strict";
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.GameEngine = exports.LevelEngine = exports.Level = exports.Cell = void 0;
const eventemitter2_1 = require("eventemitter2");
const logger_1 = require("./logger");
const rule_1 = require("./models/rule");
const astTypes_1 = require("./parser/astTypes");
const spriteBitSet_1 = require("./spriteBitSet");
const util_1 = require("./util");
class ArrayAndMap {
constructor(comparator) {
this.comparator = comparator;
this.keysArray = [];
this.map = new Map();
}
keys() { return this.keysArray; }
values() { return this.map.values(); }
set(key, value) {
if (!this.map.has(key)) {
this.insertSorted(key);
}
this.map.set(key, value);
}
get(key) {
return this.map.get(key);
}
delete(key) {
if (this.map.has(key)) {
const index = this.keysArray.indexOf(key);
if (index < 0) {
throw new Error(`BUG: Item not found`);
}
this.keysArray.splice(index, 1);
}
this.map.delete(key);
}
insertSorted(key) {
this.keysArray.push(key);
this.keysArray = this.keysArray.sort(this.comparator);
}
}
/**
* The state of sprites in one position of the current level being played.
*
* This stores all the sprites and which direction those sprites want to move.
*
* The [[TerminalUI]] uses this object to render and the [[GameEngine]] uses this to maintain the state
* of one position of the current level.
*/
class Cell {
constructor(level, sprites, rowIndex, colIndex) {
this.level = level;
this.rowIndex = rowIndex;
this.colIndex = colIndex;
this.state = new ArrayAndMap((c1, c2) => c1.id - c2.id);
this.cacheCollisionLayers = [];
this.spriteBitSet = new spriteBitSet_1.SpriteBitSet(sprites);
this.cachedKeyValue = null;
for (const sprite of sprites) {
this._setWantsToMove(sprite, util_1.RULE_DIRECTION.STATIONARY);
}
}
_setWantsToMove(sprite, wantsToMove) {
const collisionLayer = sprite.getCollisionLayer();
const { wantsToMove: cellWantsToMove, sprite: cellSprite } = this.getStateForCollisionLayer(collisionLayer);
const didActuallyChangeDir = cellWantsToMove !== wantsToMove;
const didActuallyChangeSprite = cellSprite !== sprite;
// replace the sprite in the bitSet
if (cellSprite !== sprite) {
if (cellSprite) {
throw new Error(`BUG: Should have already been removed?`);
// this.spriteBitSet.remove(cellSprite)
}
this.spriteBitSet.add(sprite);
}
this._setState(collisionLayer, sprite, wantsToMove);
// call replaceSprite only **after** we updated the Cell
if (cellSprite !== sprite) {
this.replaceSpriteInLevel(cellSprite, sprite);
}
return didActuallyChangeSprite || didActuallyChangeDir;
}
_deleteWantsToMove(sprite) {
// There may be other sprites in the same ... oh wait, no that's not possible.
const collisionLayer = sprite.getCollisionLayer();
const cellSprite = this.getSpriteByCollisionLayer(collisionLayer);
const didActuallyChange = !!cellSprite;
if (cellSprite) {
this.spriteBitSet.remove(cellSprite);
}
this._setState(collisionLayer, null, null); // delete the entry
return didActuallyChange;
}
setWantsToMoveCollisionLayer(collisionLayer, wantsToMove) {
// Check that there is a sprite for this collision layer
const { sprite, wantsToMove: cellWantsToMove } = this.getStateForCollisionLayer(collisionLayer);
if (!sprite) {
throw new Error(`BUG: No sprite for collision layer. Cannot set direction.\n${collisionLayer.toString()}`);
}
const didActuallyChange = cellWantsToMove !== wantsToMove;
this._setState(collisionLayer, sprite, wantsToMove);
sprite.updateCell(this, wantsToMove);
return didActuallyChange;
}
getSpriteByCollisionLayer(collisionLayer) {
const { sprite } = this.getStateForCollisionLayer(collisionLayer);
return sprite || null;
}
getCollisionLayers() {
// return [...this._state.keys()]
// .sort((c1, c2) => c1.id - c2.id)
return this.cacheCollisionLayers;
}
getSprites() {
// Just pull out the sprite, not the wantsToMoveDir
const sprites = [];
const collisionLayers = this.getCollisionLayers();
for (const collisionLayer of collisionLayers) {
const sprite = this.getSpriteByCollisionLayer(collisionLayer);
if (sprite) {
sprites.push(sprite);
}
}
return sprites.reverse(); // reversed so we render sprites properly
}
getSpritesAsSet() {
// SLOW: Time sink
// Just pull out the sprite, not the wantsToMoveDir
const sprites = new Set();
for (const { sprite } of this.state.values()) {
sprites.add(sprite);
}
return sprites;
}
getSpriteAndWantsToMoves() {
// Just pull out the sprite, not the wantsToMoveDir
// Retur na new set so we can mutate it later
const map = new Map();
for (const collisionLayer of this.getCollisionLayers()) {
const { sprite, wantsToMove } = this.getStateForCollisionLayer(collisionLayer);
map.set(sprite, wantsToMove);
}
return map;
}
getCollisionLayerWantsToMove(collisionLayer) {
const { wantsToMove } = this.getStateForCollisionLayer(collisionLayer);
return wantsToMove || null;
}
hasSprite(sprite) {
const cellSprite = this.getSpriteByCollisionLayer(sprite.getCollisionLayer());
return sprite === cellSprite;
}
getNeighbor(direction) {
switch (direction) {
case util_1.RULE_DIRECTION.UP:
return this.getRelativeNeighbor(-1, 0);
case util_1.RULE_DIRECTION.DOWN:
return this.getRelativeNeighbor(1, 0);
case util_1.RULE_DIRECTION.LEFT:
return this.getRelativeNeighbor(0, -1);
case util_1.RULE_DIRECTION.RIGHT:
return this.getRelativeNeighbor(0, 1);
default:
throw new Error(`BUG: Unsupported direction "${direction}"`);
}
}
getWantsToMove(sprite) {
return this.getCollisionLayerWantsToMove(sprite.getCollisionLayer());
}
hasCollisionWithSprite(otherSprite) {
return !!this.getCollisionLayerWantsToMove(otherSprite.getCollisionLayer());
}
clearWantsToMove(sprite) {
this._setWantsToMove(sprite, util_1.RULE_DIRECTION.STATIONARY);
sprite.updateCell(this, util_1.RULE_DIRECTION.STATIONARY);
}
addSprite(sprite, wantsToMove) {
let didActuallyChange = false;
// If we already have a sprite in that collision layer then we need to remove it
const prevSprite = this.getSpriteByCollisionLayer(sprite.getCollisionLayer());
const prevWantsToMove = this.getCollisionLayerWantsToMove(sprite.getCollisionLayer());
if (prevSprite && prevSprite !== sprite) {
this.removeSprite(prevSprite);
}
if (wantsToMove) {
didActuallyChange = this._setWantsToMove(sprite, wantsToMove);
}
else if (!this.hasSprite(sprite)) {
wantsToMove = prevWantsToMove || util_1.RULE_DIRECTION.STATIONARY; // try to preserve the wantsToMove
didActuallyChange = this._setWantsToMove(sprite, wantsToMove);
}
sprite.addCell(this, wantsToMove);
return didActuallyChange;
}
updateSprite(sprite, wantsToMove) {
// Copy/pasta from addSprite except it calls updateCell
let didActuallyChange = false;
// If we already have a sprite in that collision layer then we need to remove it
const prevSprite = this.getSpriteByCollisionLayer(sprite.getCollisionLayer());
if (prevSprite !== sprite) {
throw new Error(`BUG: Should not be trying to update the direction of a sprite that is not in the cell`);
}
if (wantsToMove) {
didActuallyChange = this._setWantsToMove(sprite, wantsToMove);
}
else if (!this.hasSprite(sprite)) {
throw new Error(`BUG: sprite should already be in the cell since we are updating it`);
}
sprite.updateCell(this, wantsToMove);
return didActuallyChange;
}
removeSprite(sprite) {
const didActuallyChange = this._deleteWantsToMove(sprite);
sprite.removeCell(this);
return didActuallyChange;
}
toString() {
return `Cell [${this.rowIndex}][${this.colIndex}] ${[...this.getSpriteAndWantsToMoves().entries()].map(([sprite, wantsToMove]) => `${wantsToMove} ${sprite.getName()}`).join(' ')}`;
}
toKey() {
if (!this.cachedKeyValue) {
const strs = [];
for (const { sprite, wantsToMove } of this.state.values()) {
strs.push(`${wantsToMove} ${sprite.getName()}`);
}
this.cachedKeyValue = strs.join(' ');
}
return this.cachedKeyValue;
}
toSnapshot() {
return this.getSpritesAsSet();
}
fromSnapshot(newSprites) {
const currentSprites = this.getSpritesAsSet();
const spritesToRemove = (0, util_1.setDifference)(currentSprites, newSprites);
const spritesToAdd = (0, util_1.setDifference)(newSprites, currentSprites);
// Remove Sprites
this.removeSprites(spritesToRemove);
// Add Sprites
this.addSprites(spritesToAdd);
}
// This method is replaced by LetterCells (because they are not boud to a level)
replaceSpriteInLevel(cellSprite, newSprite) {
this.getLevel().replaceSprite(this, cellSprite, newSprite);
}
_setState(collisionLayer, sprite, wantsToMove) {
let needsToUpdateCache;
if (sprite) {
needsToUpdateCache = this.cacheCollisionLayers.indexOf(collisionLayer) < 0;
this.state.set(collisionLayer, { wantsToMove, sprite });
}
else {
this.state.delete(collisionLayer);
needsToUpdateCache = true;
}
if (needsToUpdateCache) {
// Update the collisionLayer Cache
this.cacheCollisionLayers = this.state.keys();
}
this.invalidateKey();
}
getLevel() {
if (!this.level) {
throw new Error(`BUG: we need an engine Level in order to find neighbors. It is optional for letters in messages`);
}
return this.level;
}
getStateForCollisionLayer(collisionLayer) {
const state = this.state.get(collisionLayer);
if (!state) {
return { wantsToMove: null, sprite: null };
}
return state;
}
getRelativeNeighbor(y, x) {
return this.getLevel().getCellOrNull(this.rowIndex + y, this.colIndex + x);
}
removeSprites(sprites) {
for (const sprite of sprites) {
this.removeSprite(sprite);
}
}
addSprites(sprites) {
for (const sprite of sprites) {
this.addSprite(sprite, null);
}
}
invalidateKey() {
this.cachedKeyValue = null;
}
}
exports.Cell = Cell;
class Level {
constructor() {
this.rowCache = [];
this.colCache = [];
this.cells = null;
}
setCells(cells) {
this.cells = cells;
}
getCells() {
if (!this.cells) {
throw new Error(`BUG: Should have called setCells() first`);
}
return this.cells;
}
getCellOrNull(rowIndex, colIndex) {
const row = this.getCells()[rowIndex];
if (row) {
return row[colIndex];
}
return null;
}
getCell(rowIndex, colIndex) {
// Skip error checks for performance
return this.getCells()[rowIndex][colIndex];
}
replaceSprite(cell, oldSprite, newSprite) {
// When a new Cell is instantiated it will call this method but `this.cells` is not defined yet
if (this.cells) {
// Invalidate the row/column cache. It will be rebuilt when requested
this.rowCache[cell.rowIndex] = null;
this.colCache[cell.colIndex] = null;
}
}
rowContainsSprites(rowIndex, spritesPresent, anySpritesPresent) {
let cache = this.rowCache[rowIndex];
if (!cache) {
cache = this.computeRowCache(rowIndex);
this.rowCache[rowIndex] = cache;
}
return cache.containsAll(spritesPresent) && anySpritesPresent.isEmpty() ? true : cache.containsAny(anySpritesPresent);
}
colContainsSprites(colIndex, sprites, anySpritesPresent) {
let cache = this.colCache[colIndex];
if (!cache) {
cache = this.computeColCache(colIndex);
this.colCache[colIndex] = cache;
}
return cache.containsAll(sprites) && anySpritesPresent.isEmpty() ? true : cache.containsAny(anySpritesPresent);
}
computeRowCache(rowIndex) {
const cols = this.getCells()[0].length;
const bitSets = [];
for (let index = 0; index < cols; index++) {
bitSets.push(this.getCell(rowIndex, index).spriteBitSet);
}
return (new spriteBitSet_1.SpriteBitSet()).union(bitSets);
}
computeColCache(colIndex) {
const rows = this.getCells().length;
const bitSets = [];
for (let index = 0; index < rows; index++) {
bitSets.push(this.getCell(index, colIndex).spriteBitSet);
}
return (new spriteBitSet_1.SpriteBitSet()).union(bitSets);
}
}
exports.Level = Level;
/**
* Internal class that ise used to maintain the state of a level.
*
* This should not be called directly. Instead, use [[GameEngine]] .
*/
class LevelEngine extends eventemitter2_1.EventEmitter2 {
constructor(gameData) {
super();
this.gameData = gameData;
this.hasAgainThatNeedsToRun = false;
this.undoStack = [];
this.pendingPlayerWantsToMove = null;
this.currentLevel = null;
this.tempOldLevel = null;
}
setLevel(levelNum) {
this.undoStack = [];
this.gameData.clearCaches();
const levelData = this.gameData.levels[levelNum];
if (!levelData) {
throw new Error(`Invalid levelNum: ${levelNum}`);
}
if (levelData.type === astTypes_1.LEVEL_TYPE.MAP) {
(0, util_1.resetRandomSeed)();
const levelSprites = levelData.cells.map((row) => {
return row.map((col) => {
const sprites = new Set(col.getSprites());
const backgroundSprite = this.gameData.getMagicBackgroundSprite();
if (backgroundSprite) {
sprites.add(backgroundSprite);
}
return sprites;
});
});
// Clone the board because we will be modifying it
this._setLevel(levelSprites);
if (this.gameData.metadata.runRulesOnLevelStart) {
const { messageToShow, isWinning, hasRestart } = this.tick();
if (messageToShow || isWinning || hasRestart) {
console.log(`Error: Game should not cause a sound/message/win/restart during the initial tick. "${messageToShow}" "${isWinning}" "${hasRestart}"`); // tslint:disable-line:no-console
}
}
this.takeSnapshot(this.createSnapshot());
// Return the cells so the UI can listen to when they change
return this.getCells();
}
else {
throw new Error(`BUG: LEVEL_MESSAGE should not reach this point`);
}
}
setMessageLevel(sprites) {
this.tempOldLevel = this.currentLevel;
this._setLevel(sprites);
}
restoreFromMessageLevel() {
this.currentLevel = this.tempOldLevel;
this.tempOldLevel = null;
// this.setLevel(this.tempOldLevel)
}
getCurrentLevel() {
if (this.currentLevel) {
return this.currentLevel;
}
else {
throw new Error(`BUG: There is no current level. Maybe it is a message level or maybe setLevel was never called`);
}
}
toSnapshot() {
return this.getCurrentLevel().getCells().map((row) => {
return row.map((cell) => {
const ret = [];
cell.getSpriteAndWantsToMoves().forEach((wantsToMove, sprite) => {
ret.push(`${wantsToMove} ${sprite.getName()}`);
});
return ret;
});
});
}
tick() {
logger_1.logger.debug(() => ``);
if (this.hasAgainThatNeedsToRun) {
// run the AGAIN rules
this.hasAgainThatNeedsToRun = false; // let the .tick() make it true
}
switch (this.pendingPlayerWantsToMove) {
case util_1.INPUT_BUTTON.UNDO:
this.doUndo();
this.pendingPlayerWantsToMove = null;
return {
changedCells: new Set(this.getCells()),
soundToPlay: null,
messageToShow: null,
hasCheckpoint: false,
hasRestart: false,
isWinning: false,
mutations: [],
a11yMessages: []
};
case util_1.INPUT_BUTTON.RESTART:
this.doRestart();
this.pendingPlayerWantsToMove = null;
return {
changedCells: new Set(this.getCells()),
soundToPlay: null,
messageToShow: null,
hasCheckpoint: false,
hasRestart: true,
isWinning: false,
mutations: [],
a11yMessages: []
};
default:
// no-op
}
const ret = this.tickNormal();
// TODO: Handle the commands like RESTART, CANCEL, WIN at this point
let soundToPlay = null;
let messageToShow = null;
let hasCheckpoint = false;
let hasWinCommand = false;
let hasRestart = false;
for (const command of ret.commands) {
switch (command.type) {
case astTypes_1.COMMAND_TYPE.RESTART:
hasRestart = true;
break;
case astTypes_1.COMMAND_TYPE.SFX:
soundToPlay = command.sound;
break;
case astTypes_1.COMMAND_TYPE.MESSAGE:
this.hasAgainThatNeedsToRun = false; // make sure we won't be waiting on another tick
messageToShow = command.message;
break;
case astTypes_1.COMMAND_TYPE.WIN:
hasWinCommand = true;
break;
case astTypes_1.COMMAND_TYPE.CHECKPOINT:
hasCheckpoint = true;
break;
case astTypes_1.COMMAND_TYPE.AGAIN:
case astTypes_1.COMMAND_TYPE.CANCEL:
break;
default:
throw new Error(`BUG: Unsupported command "${command}"`);
}
}
logger_1.logger.debug(() => `checking win condition.`);
if (this.hasAgainThatNeedsToRun) {
logger_1.logger.debug(() => `AGAIN command executed, with changes detected - will execute another turn.`);
}
return {
changedCells: new Set(ret.changedCells.keys()),
hasCheckpoint,
soundToPlay,
messageToShow,
hasRestart,
isWinning: hasWinCommand || this.isWinning(),
mutations: ret.mutations,
a11yMessages: ret.a11yMessages
};
}
hasAgain() {
return this.hasAgainThatNeedsToRun;
}
canUndo() {
return this.undoStack.length > 1;
}
press(button) {
return this.pressDir(button);
}
tickUpdateCells() {
logger_1.logger.debug(() => `applying rules`);
return this._tickUpdateCells(this.gameData.rules.filter((r) => !r.isLate()));
}
tickMoveSprites(changedCells) {
const movedCells = new Set();
const a11yMessages = [];
// Loop over all the cells, see if a Rule matches, apply the transition, and notify that cells changed
let somethingChanged;
do {
somethingChanged = false;
for (const cell of changedCells) {
for (const [sprite, wantsToMove] of cell.getSpriteAndWantsToMoves()) {
switch (wantsToMove) {
case util_1.RULE_DIRECTION.STATIONARY:
// nothing to do
break;
case util_1.RULE_DIRECTION.ACTION:
// just clear the wantsToMove flag
somethingChanged = true;
cell.clearWantsToMove(sprite);
break;
case util_1.RULE_DIRECTION.UP:
case util_1.RULE_DIRECTION.DOWN:
case util_1.RULE_DIRECTION.LEFT:
case util_1.RULE_DIRECTION.RIGHT:
const neighbor = cell.getNeighbor(wantsToMove);
// Make sure
if (neighbor && !neighbor.hasCollisionWithSprite(sprite)) {
cell.removeSprite(sprite);
neighbor.addSprite(sprite, util_1.RULE_DIRECTION.STATIONARY);
movedCells.add(neighbor);
movedCells.add(cell);
somethingChanged = true;
a11yMessages.push({ type: rule_1.A11Y_MESSAGE_TYPE.MOVE, oldCell: cell, newCell: neighbor, sprite, direction: wantsToMove });
// Don't delete until we are sure none of the sprites want to move
// changedCells.delete(cell)
}
else {
// Clear the wantsToMove flag LATER if we hit a wall (a sprite in the same collisionLayer) or are at the end of the map
// We do this later because we are looping as long as something changed
// cell.clearWantsToMove(sprite)
}
break;
default:
throw new Error(`BUG: wantsToMove should have been handled earlier: ${wantsToMove}`);
}
}
}
} while (somethingChanged);
// Clear the wantsToMove from all remaining cells
for (const cell of changedCells) {
for (const [sprite] of cell.getSpriteAndWantsToMoves()) {
cell.clearWantsToMove(sprite);
}
}
return { movedCells, a11yMessages };
}
// Used for UNDO and RESTART
createSnapshot() {
return this.getCurrentLevel().getCells().map((row) => row.map((cell) => cell.toSnapshot()));
}
pressDir(direction) {
// Should disable keypresses if `AGAIN` is running.
// It is commented because the didSpritesChange logic is not correct.
// a rule might add a sprite, and then another rule might remove a sprite.
// We need to compare the set of sprites before and after ALL rules ran.
// This will likely be implemented as part of UNDO or CHECKPOINT.
// if (!this.hasAgain()) {
this.pendingPlayerWantsToMove = direction;
// }
}
doRestart() {
// Add the initial checkpoint to the top (rather than clearing the stack)
// so the player can still "UNDO" after pressing "RESTART"
const snapshot = this.undoStack[0];
this.undoStack.push(snapshot);
this.applySnapshot(snapshot);
}
doUndo() {
const snapshot = this.undoStack.pop();
if (snapshot && this.undoStack.length > 0) { // the 0th entry is the initial load of the level
this.applySnapshot(snapshot);
}
else if (snapshot) {
// oops, put the snapshot back on the stack
this.undoStack.push(snapshot);
}
}
_setLevel(levelSprites) {
const level = new Level();
this.currentLevel = level;
const spriteCells = levelSprites.map((row, rowIndex) => {
return row.map((sprites, colIndex) => {
const backgroundSprite = this.gameData.getMagicBackgroundSprite();
if (backgroundSprite) {
sprites.add(backgroundSprite);
}
return new Cell(level, sprites, rowIndex, colIndex);
});
});
level.setCells(spriteCells);
// link up all the cells. Loop over all the sprites
// in case they are NO tiles (so the cell is included)
const batchCells = new Map();
function spriteSetToKey(sprites) {
const key = [];
for (const spriteName of [...sprites].map((sprite) => sprite.getName()).sort()) {
key.push(spriteName);
}
return key.join(' ');
}
const allCells = this.getCells();
// But first, fill up any empty condition brackets with ALL THE CELLS
for (const rule of this.gameData.rules) {
rule.addCellsToEmptyRules(allCells);
}
for (const cell of allCells) {
const key = spriteSetToKey(cell.getSpritesAsSet());
let batch = batchCells.get(key);
if (!batch) {
batch = [];
batchCells.set(key, batch);
}
batch.push(cell);
}
// Print progress while loading up the Cells
let i = 0;
for (const [key, cells] of batchCells) {
if ((batchCells.size > 100 && i % 10 === 0) || cells.length > 100) {
this.emit('loading-cells', {
cellStart: i,
cellEnd: i + cells.length,
cellTotal: allCells.length,
key
});
}
// All Cells contain the same set of sprites so just pull out the 1st one
for (const sprite of this.gameData.objects) {
const cellSprites = cells[0].getSpritesAsSet();
const hasSprite = cellSprites.has(sprite);
if (hasSprite || sprite.hasNegationTileWithModifier()) {
if (hasSprite) {
sprite.addCells(sprite, cells, util_1.RULE_DIRECTION.STATIONARY);
}
else {
sprite.removeCells(sprite, cells);
}
}
}
i += cells.length;
}
return level;
}
getCells() {
return (0, util_1._flatten)(this.getCurrentLevel().getCells());
}
tickUpdateCellsLate() {
logger_1.logger.debug(() => `applying late rules`);
return this._tickUpdateCells(this.gameData.rules.filter((r) => r.isLate()));
}
_tickUpdateCells(rules) {
const changedMutations = new Set();
const a11yMessages = [];
const evaluatedRules = [];
if (!this.currentLevel) {
throw new Error(`BUG: Level Cells do not exist yet`);
}
for (const rule of rules) {
const cellMutations = rule.evaluate(this.currentLevel, false /*evaluate all rules*/);
if (cellMutations.length > 0) {
evaluatedRules.push(rule);
}
for (const mutation of cellMutations) {
changedMutations.add(mutation);
for (const message of mutation.messages) {
a11yMessages.push(message);
}
}
}
// We may have mutated the same cell 4 times (e.g. [Player]->[>Player]) so consolidate
const changedCells = new Set();
const commands = new Set();
for (const mutation of changedMutations) {
if (mutation.hasCell()) {
changedCells.add(mutation.getCell());
}
else {
commands.add(mutation.getCommand());
}
}
return { evaluatedRules, changedCells, commands, mutations: changedMutations, a11yMessages };
}
tickNormal() {
let changedCellMutations = new Set();
const initialSnapshot = this.createSnapshot();
if (this.pendingPlayerWantsToMove) {
this.takeSnapshot(initialSnapshot);
logger_1.logger.debug(`=======================\nTurn starts with input of ${this.pendingPlayerWantsToMove.toLowerCase()}.`);
const t = this.gameData.getPlayer();
for (const cell of t.getCellsThatMatch((0, util_1._flatten)(this.getCurrentLevel().getCells()))) {
for (const sprite of t.getSpritesThatMatch(cell)) {
cell.updateSprite(sprite, inputButtonToRuleDirection(this.pendingPlayerWantsToMove));
changedCellMutations.add(cell);
}
}
this.pendingPlayerWantsToMove = null;
}
else {
logger_1.logger.debug(() => `Turn starts with no input.`);
}
const { changedCells: changedCellMutations2, evaluatedRules, commands, mutations, a11yMessages: a11yMessages1 } = this.tickUpdateCells();
changedCellMutations = (0, util_1.setAddAll)(changedCellMutations, changedCellMutations2);
// Continue evaluating again rules only when some sprites have changed
// The didSpritesChange logic is not correct.
// a rule might add a sprite, and then another rule might remove a sprite.
// We need to compare the set of sprites before and after ALL rules ran.
// This will likely be implemented as part of UNDO or CHECKPOINT.
const { movedCells, a11yMessages: a11yMessages2 } = this.tickMoveSprites(new Set(changedCellMutations.keys()));
const { changedCells: changedCellsLate, evaluatedRules: evaluatedRulesLate, commands: commandsLate, mutations: mutationsLate, a11yMessages: a11yMessages3 } = this.tickUpdateCellsLate();
const allCommands = [...commands, ...commandsLate];
const didCancel = !!allCommands.filter((c) => c.type === astTypes_1.COMMAND_TYPE.CANCEL)[0];
if (didCancel) {
this.hasAgainThatNeedsToRun = false;
if (this.undoStack.length > 0) {
this.applySnapshot(this.undoStack[this.undoStack.length - 1]);
}
return {
changedCells: new Set(),
checkpoint: null,
commands: new Set(),
evaluatedRules,
mutations: new Set(),
a11yMessages: []
};
}
let checkpoint = null;
const didCheckpoint = !!allCommands.find((c) => c.type === astTypes_1.COMMAND_TYPE.CHECKPOINT);
if (didCheckpoint) {
this.undoStack = [];
checkpoint = this.createSnapshot();
this.takeSnapshot(checkpoint);
}
// set this only if we did not CANCEL and if some cell changed
const changedCells = (0, util_1.setAddAll)((0, util_1.setAddAll)(changedCellMutations, changedCellsLate), movedCells);
if (allCommands.find((c) => c.type === astTypes_1.COMMAND_TYPE.AGAIN)) {
// Compare all the cells to the top of the undo stack. If it does not differ
this.hasAgainThatNeedsToRun = this.doSnapshotsDiffer(initialSnapshot, this.createSnapshot());
}
// reduce the changedCells based on what was in the cell before the tick
const realChangedCells = this.getRealChangedCells(initialSnapshot, changedCells);
const realA11yMessages = this.getRealA11yMessages(realChangedCells, [...a11yMessages1, ...a11yMessages2, ...a11yMessages3]);
return {
changedCells: realChangedCells,
evaluatedRules: evaluatedRules.concat(evaluatedRulesLate),
commands: allCommands,
mutations: new Set([...mutations, ...mutationsLate]),
a11yMessages: realA11yMessages
};
}
getRealA11yMessages(changedCells, a11yMessages) {
return a11yMessages.filter((m) => {
switch (m.type) {
case rule_1.A11Y_MESSAGE_TYPE.ADD:
case rule_1.A11Y_MESSAGE_TYPE.REPLACE:
case rule_1.A11Y_MESSAGE_TYPE.REMOVE:
return changedCells.has(m.cell);
case rule_1.A11Y_MESSAGE_TYPE.MOVE:
return changedCells.has(m.oldCell) || changedCells.has(m.newCell);
}
});
}
getRealChangedCells(initialSnapshot, changedCells) {
const realChangedCells = new Set();
for (const cell of changedCells) {
if (!(0, util_1.setEquals)(cell.getSpritesAsSet(), initialSnapshot[cell.rowIndex][cell.colIndex])) {
realChangedCells.add(cell);
}
}
return realChangedCells;
}
isWinning() {
let conditionsSatisfied = this.gameData.winConditions.length > 0;
this.gameData.winConditions.forEach((winCondition) => {
if (!winCondition.isSatisfied(this.getCells())) {
conditionsSatisfied = false;
}
});
return conditionsSatisfied;
}
takeSnapshot(snapshot) {
this.undoStack.push(snapshot);
}
applySnapshot(snpashot) {
const cells = this.getCurrentLevel().getCells();
for (let rowIndex = 0; rowIndex < cells.length; rowIndex++) {
const row = cells[rowIndex];
const snapshotRow = snpashot[rowIndex];
for (let colIndex = 0; colIndex < row.length; colIndex++) {
const cell = row[colIndex];
const state = snapshotRow[colIndex];
cell.fromSnapshot(state);
}
}
}
doSnapshotsDiffer(snapshot1, snapshot2) {
for (let rowIndex = 0; rowIndex < snapshot1.length; rowIndex++) {
for (let colIndex = 0; colIndex < snapshot1[0].length; colIndex++) {
const sprites1 = snapshot1[rowIndex][colIndex];
const sprites2 = snapshot2[rowIndex][colIndex];
if (!(0, util_1.setEquals)(sprites1, sprites2)) {
return true;
}
}
}
return false;
}
}
exports.LevelEngine = LevelEngine;
/**
* Maintains the state of the game. Here is an example flow:
*
* ```js
* const engine = new GameEngine(gameData)
* engine.setLevel(0)
* engine.pressRight()
* engine.tick()
* engine.tick()
* engine.pressUp()
* engine.tick()
* engine.pressUndo()
* engine.tick()
* ```
*/
class GameEngine {
constructor(gameData, handler) {
this.currentLevelNum = -1234567;
this.handler = handler;
this.levelEngine = new LevelEngine(gameData);
}
on(eventName, handler) {
this.levelEngine.on(eventName, handler);
}
getGameData() {
return this.levelEngine.gameData;
}
getCurrentLevelCells() {
return this.levelEngine.getCurrentLevel().getCells();
}
getCurrentLevel() {
return this.getGameData().levels[this.getCurrentLevelNum()];
}
getCurrentLevelNum() {
return this.currentLevelNum;
}
hasAgain() {
return this.levelEngine.hasAgain();
}
setLevel(levelNum, checkpoint) {
this.levelEngine.hasAgainThatNeedsToRun = false;
this.currentLevelNum = levelNum;
const level = this.getGameData().levels[levelNum];
if (level.type === astTypes_1.LEVEL_TYPE.MAP) {
this.handler.onLevelLoad(levelNum, { rows: level.cells.length, cols: level.cells[0].length });
this.levelEngine.setLevel(levelNum);
if (checkpoint) {
this.loadSnapshotFromJSON(checkpoint);
}
this.handler.onLevelChange(this.currentLevelNum, this.levelEngine.getCurrentLevel().getCells(), null);
}
else {
this.handler.onLevelLoad(levelNum, null);
this.handler.onLevelChange(this.currentLevelNum, null, level.message);
}
}
tick() {
return __awaiter(this, void 0, void 0, function* () {
// When the current level is a Message, wait until the user presses ACTION
const currentLevel = this.getCurrentLevel();
if (currentLevel.type === astTypes_1.LEVEL_TYPE.MESSAGE) {
yield this.handler.onMessage(currentLevel.message);
let didWinGameInMessage = false;
if (this.currentLevelNum === this.levelEngine.gameData.levels.length - 1) {
this.handler.onWin();
didWinGameInMessage = true;
}
else {
this.setLevel(this.currentLevelNum + 1, null /*no checkpoint*/);
}
// clear any keys that were pressed
this.levelEngine.pendingPlayerWantsToMove = null;
return {
changedCells: new Set(),
didWinGame: didWinGameInMessage,
didLevelChange: true,
wasAgainTick: false
};
}
let hasAgain = this.levelEngine.hasAgain();
if (!hasAgain && !(this.levelEngine.gameData.metadata.realtimeInterval || this.levelEngine.pendingPlayerWantsToMove)) {
return {
changedCells: new Set(),
didWinGame: false,
didLevelChange: false,
wasAgainTick: false
};
}
const previousPending = this.levelEngine.pendingPlayerWantsToMove;
const { changedCells, hasCheckpoint, soundToPlay, messageToShow, isWinning, hasRestart, a11yMessages } = this.levelEngine.tick();
if (previousPending && !this.levelEngine.pendingPlayerWantsToMove) {
this.handler.onPress(previousPending);
}
const checkpoint = hasCheckpoint ? this.saveSnapshotToJSON() : null;
if (hasRestart) {
this.handler.onTick(changedCells, checkpoint, hasAgain, a11yMessages);
return {
changedCells,
didWinGame: false,
didLevelChange: false,
wasAgainTick: false
};
}
hasAgain = this.levelEngine.hasAgain();
this.handler.onTick(changedCells, checkpoint, hasAgain, a11yMessages);
let didWinGame = false;
if (isWinning) {
if (this.currentLevelNum === this.levelEngine.gameData.levels.length - 1) {
didWinGame = true;
this.handler.onWin();
}
else {
this.setLevel(this.currentLevelNum + 1, null /*no checkpoint*/);
}
}
if (soundToPlay) {
yield this.handler.onSound(soundToPlay);
}
if (messageToShow) {
yield this.handler.onMessage(messageToShow);
}
return {
changedCells,
didWinGame,
didLevelChange: isWinning,
wasAgainTick: hasAgain
};
});
}
press(direction) {
this.levelEngine.press(direction);
}
saveSnapshotToJSON() {
return this.getCurrentLevelCells().map((row) => row.map((cell) => [...cell.toSnapshot()].map((s) => s.getName())));
}
loadSnapshotFromJSON(json) {
json.forEach((rowSave, rowIndex) => {
rowSave.forEach((cellSave, colIndex) => {
const cell = this.levelEngine.getCurrentLevel().getCell(rowIndex, colIndex);
const spritesToHave = cellSave.map((spriteName) => {
const sprite = this.getGameData()._getSpriteByName(spriteName);
if (sprite) {
return sprite;
}
else {
throw new Error(`BUG: Could not find sprite to add named ${spriteName}`);
}
});
cell.fromSnapshot(new Set(spritesToHave));
});
});
}
isCurrentLevelAMessage() {
return this.getCurrentLevel().type === astTypes_1.LEVEL_TYPE.MESSAGE;
}
}
exports.GameEngine = GameEngine;
function inputButtonToRuleDirection(button) {
switch (button) {
case util_1.INPUT_BUTTON.UP: return util_1.RULE_DIRECTION.UP;
case util_1.INPUT_BUTTON.DOWN: return util_1.RULE_DIRECTION.DOWN;
case util_1.INPUT_BUTTON.LEFT: return util_1.RULE_DIRECTION.LEFT;
case util_1.INPUT_BUTTON.RIGHT: return util_1.RULE_DIRECTION.RIGHT;
case util_1.INPUT_BUTTON.ACTION: return util_1.RULE_DIRECTION.ACTION;
default:
throw new Error(`BUG: Invalid input button at this point. Only up/down/left/right/action are allowed. "${button}"`);
}
}
//# sourceMappingURL=engine.js.map