import { GameData } from '.' import { Cell, CellSaveState } from './engine' import { GameMetadata } from './models/metadata' import { A11Y_MESSAGE } from './models/rule' import { GameSprite } from './models/tile' import { Soundish } from './parser/astTypes' export type Optional = T | null export enum RULE_DIRECTION { UP = 'UP', DOWN = 'DOWN', LEFT = 'LEFT', RIGHT = 'RIGHT', ACTION = 'ACTION', STATIONARY = 'STATIONARY', RANDOMDIR = 'RANDOMDIR' } export enum INPUT_BUTTON { UP = 'UP', DOWN = 'DOWN', LEFT = 'LEFT', RIGHT = 'RIGHT', ACTION = 'ACTION', UNDO = 'UNDO', RESTART = 'RESTART' } export enum RULE_DIRECTION_RELATIVE { RELATIVE_LEFT = '<', RELATIVE_RIGHT = '>', RELATIVE_UP = '^', RELATIVE_DOWN = 'V' } export type RULE_DIRECTION_WITH_RELATIVE = RULE_DIRECTION | RULE_DIRECTION_RELATIVE // From https://stackoverflow.com/questions/10865025/merge-flatten-an-array-of-arrays-in-javascript/39000004#39000004 export function _flatten(arrays: T[][]) { // return [].concat.apply([], arrays) as T[] const ret: T[] = [] arrays.forEach((ary) => { ary.forEach((item) => { ret.push(item) }) }) return ret } // export function filterNulls(items: Array>) { // const ret: T[] = [] // items.forEach((x) => { // if (x) { ret.push(x) } // }) // return ret // } // export function _zip(array1: T1[], array2: T2[]) { // if (array1.length < array2.length) { // throw new Error(`BUG: Zip array length mismatch ${array1.length} != ${array2.length}`) // } // return array1.map((v1, index) => { // return [v1, array2[index]] // }) // } // export function _extend(dest: any, ...rest: any[]) { // for (const obj of rest) { // for (const key of Object.keys(obj)) { // dest[key] = obj[key] // } // } // return dest // } export function _debounce(callback: () => any) { let timeout: any// NodeJS.Timer return () => { if (timeout) { clearTimeout(timeout) } timeout = setTimeout(() => { callback() }, 10) } } export function opposite(dir: RULE_DIRECTION) { switch (dir) { case RULE_DIRECTION.UP: return RULE_DIRECTION.DOWN case RULE_DIRECTION.DOWN: return RULE_DIRECTION.UP case RULE_DIRECTION.LEFT: return RULE_DIRECTION.RIGHT case RULE_DIRECTION.RIGHT: return RULE_DIRECTION.LEFT default: throw new Error(`BUG: Invalid direction: "${dir}"`) } } export function setEquals(set1: Set, set2: Set) { if (set1.size !== set2.size) return false for (const elem of set2) { if (!set1.has(elem)) return false } return true } export function setAddAll(setA: Set, iterable: Iterable) { const newSet = new Set(setA) for (const elem of iterable) { newSet.add(elem) } return newSet } export function setIntersection(setA: Set, setB: Iterable) { const intersection = new Set() for (const elem of setB) { if (setA.has(elem)) { intersection.add(elem) } } return intersection } export function setDifference(setA: Set, setB: Iterable) { const difference = new Set(setA) for (const elem of setB) { difference.delete(elem) } return difference } // From https://stackoverflow.com/a/19303725 let seed = 1 let randomValuesForTesting: Optional = null export function nextRandom(maxNonInclusive: number) { if (randomValuesForTesting) { if (randomValuesForTesting.length <= seed - 1) { throw new Error(`BUG: the list of random values for testing was too short. See calls to setRandomValuesForTesting([...]). The list was [${randomValuesForTesting}]. Index being requested is ${seed - 1}`) } const ret = randomValuesForTesting[seed - 1] seed++ // console.log(`Sending "random" value of "${ret}"`); return ret } const x = Math.sin(seed++) * 10000 return Math.round((x - Math.floor(x)) * (maxNonInclusive - 1)) // return Math.round(Math.random() * (maxNonInclusive - 1)) } export function resetRandomSeed() { seed = 1 } export function setRandomValuesForTesting(values: number[]) { randomValuesForTesting = values resetRandomSeed() } export function clearRandomValuesForTesting() { randomValuesForTesting = null resetRandomSeed() } export function getRandomSeed() { return seed } /** * A `DEBUGGER` flag in the game source that causes the evaluation to pause. * It works like the * [debugger](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/debugger) * keyword in JavaScript. * * **Note:** the game needs to run in debug mode (`node --inspect-brk path/to/puzzlescript.js` or `npm run play-debug`) * for this flag to have any effect. * * This string can be added to: * * - A Rule. Example: `DEBUGGER [ > player | cat ] -> [ > player | > cat ]` * - A bracket when the condition is updated: `[ > player | cat ] DEBUGGER -> [ > player | > cat ]` * - A bracket when it is evaluated: `[ > player | cat ] -> [ > player | > cat ] DEBUGGER` * - A neighbor when the condition is updated: `[ > player DEBUGGER | cat ] -> [ > player | > cat ]` * - A neighbor when it is evaluated: `[ > player | cat ] -> [ > player | > cat DEBUGGER ]` * - A tile when the condition is updated: `[ > player | DEBUGGER cat ] -> [ > player | > cat ]` * - A tile when it is matched: `[ > player | cat ] -> [ > player | DEBUGGER > cat ]` */ export enum DEBUG_FLAG { BREAKPOINT = 'DEBUGGER', // only when the rule matches elements /** * Pause when a Cell causes an entry to be removed from the set of matches for this rule/bracket/neighbor/tile */ BREAKPOINT_REMOVE = 'DEBUGGER_REMOVE' } export interface ICacheable { toKey: () => string } export function spritesThatInteractWithPlayer(game: GameData) { const playerSprites = game.getPlayer().getSprites() const interactsWithPlayer = new Set(playerSprites) // Add all the sprites in the same collision layer as the Player for (const playerSprite of interactsWithPlayer) { const collisionLayer = playerSprite.getCollisionLayer() for (const sprite of game.objects) { if (sprite.getCollisionLayer() === collisionLayer) { interactsWithPlayer.add(sprite) } } } // Add all the winCondition sprites for (const win of game.winConditions) { for (const tile of win.a11yGetTiles()) { for (const sprite of tile.getSprites()) { interactsWithPlayer.add(sprite) } } } // Add all the other sprites that interact with the player for (const rule of game.rules) { for (const sprites of rule.a11yGetConditionSprites()) { if (setIntersection(sprites, interactsWithPlayer).size > 0) { for (const sprite of sprites) { interactsWithPlayer.add(sprite) } } } } // remove the background sprite (even though it transitively interacts) const background = game.getMagicBackgroundSprite() if (background) { interactsWithPlayer.delete(background) } // remove transparent sprites once the dependecies are found return new Set([...interactsWithPlayer].filter((s) => !s.isTransparent())) } // Webworker message interfaces // Polls until a condition is true export function pollingPromise(ms: number, fn: () => T) { return new Promise((resolve) => { const timer = setInterval(() => { const value = fn() if (value) { clearInterval(timer) resolve(value) } }, ms) }) } export interface TypedMessageEvent extends MessageEvent { data: T } export enum MESSAGE_TYPE { PAUSE = 'PAUSE', RESUME = 'RESUME', TICK = 'TICK', PRESS = 'PRESS', CLOSE = 'CLOSE', // Event handler events ON_GAME_CHANGE = 'ON_GAME_CHANGE', ON_PRESS = 'ON_PRESS', ON_MESSAGE = 'ON_MESSAGE', ON_MESSAGE_DONE = 'ON_MESSAGE_DONE', ON_LEVEL_LOAD = 'ON_LEVEL_LOAD', ON_LEVEL_CHANGE = 'ON_LEVEL_CHANGE', ON_WIN = 'ON_WIN', ON_SOUND = 'ON_SOUND', ON_TICK = 'ON_TICK', ON_PAUSE = 'ON_PAUSE', ON_RESUME = 'ON_RESUME' } export interface CellishJson { colIndex: number, rowIndex: number, spriteNames: string[] } export interface SerializedTickResult { changedCells: CellishJson[] soundToPlay: Optional messageToShow: Optional didWinGame: boolean didLevelChange: boolean wasAgainTick: boolean, a11yMessages: A11Y_MESSAGE } export type WorkerMessage = { type: MESSAGE_TYPE.ON_GAME_CHANGE code: ArrayBuffer level: number checkpoint: Optional } | { type: MESSAGE_TYPE.PRESS button: INPUT_BUTTON } | { type: MESSAGE_TYPE.CLOSE } | { type: MESSAGE_TYPE.PAUSE } | { type: MESSAGE_TYPE.RESUME } | { type: MESSAGE_TYPE.ON_MESSAGE_DONE } export type WorkerResponse = { type: MESSAGE_TYPE.ON_GAME_CHANGE payload: ArrayBuffer // IGraphJson } | { type: MESSAGE_TYPE.TICK payload: SerializedTickResult } | { type: MESSAGE_TYPE.PRESS payload: void } | { type: MESSAGE_TYPE.CLOSE payload: void } | { type: MESSAGE_TYPE.PAUSE payload: void } | { type: MESSAGE_TYPE.RESUME payload: void } | { type: MESSAGE_TYPE.ON_PRESS direction: INPUT_BUTTON } | { type: MESSAGE_TYPE.ON_MESSAGE message: string } | { type: MESSAGE_TYPE.ON_LEVEL_LOAD level: number levelSize: Optional<{rows: number, cols: number}> } | { type: MESSAGE_TYPE.ON_LEVEL_CHANGE level: number cells: Optional message: Optional } | { type: MESSAGE_TYPE.ON_WIN } | { type: MESSAGE_TYPE.ON_PAUSE } | { type: MESSAGE_TYPE.ON_RESUME } | { type: MESSAGE_TYPE.ON_SOUND soundCode: number } | { type: MESSAGE_TYPE.ON_TICK changedCells: CellishJson[] checkpoint: Optional hasAgain: boolean a11yMessages: Array> } export interface PuzzlescriptWorker { postMessage(msg: WorkerMessage, transferrables?: Transferable[]): void addEventListener(type: 'message', handler: (msg: {data: WorkerResponse}) => void): void } export const shouldTick = (metadata: GameMetadata, lastTick: number) => { const now = Date.now() let minTime = Math.min(metadata.realtimeInterval || 1000, metadata.keyRepeatInterval || 1000, metadata.againInterval || 1000) if (minTime > 100 || Number.isNaN(minTime)) { minTime = .01 } return (now - lastTick) >= (minTime * 1000) } // This interface is so the WebWorker does not have to instantiate Cells just to render to the screen export interface Cellish { colIndex: number rowIndex: number getSprites(): GameSprite[] getSpritesAsSet(): Set getWantsToMove(sprite: GameSprite): Optional } export interface GameEngineHandler { onGameChange(gameData: GameData): void onPress(dir: INPUT_BUTTON): void onMessage(msg: string): Promise onLevelLoad(level: number, newLevelSize: Optional<{rows: number, cols: number}>): void onLevelChange(level: number, cells: Optional, message: Optional): void onWin(): void onSound(sound: Soundish): Promise onTick(changedCells: Set, checkpoint: Optional, hasAgain: boolean, a11yMessages: Array>): void onPause(): void onResume(): void // onGameChange(data: GameData): void } export interface GameEngineHandlerOptional { onGameChange?(gameData: GameData): void onPress?(dir: INPUT_BUTTON): void onMessage?(msg: string): Promise onLevelLoad?(level: number, newLevelSize: Optional<{rows: number, cols: number}>): void onLevelChange?(level: number, cells: Optional, message: Optional): void onWin?(): void onSound?(sound: Soundish): Promise onTick?(changedCells: Set, checkpoint: Optional, hasAgain: boolean, a11yMessages: Array>): void onPause?(): void onResume?(): void // onGameChange?(data: GameData): void } export class EmptyGameEngineHandler implements GameEngineHandler { private subHandlers: GameEngineHandlerOptional[] constructor(subHandlers?: GameEngineHandlerOptional[]) { this.subHandlers = subHandlers || [] } public onGameChange(gameData: GameData) { for (const h of this.subHandlers) { h.onGameChange && h.onGameChange(gameData) } } public onPress(dir: INPUT_BUTTON) { for (const h of this.subHandlers) { h.onPress && h.onPress(dir) } } public async onMessage(msg: string) { for (const h of this.subHandlers) { h.onMessage && await h.onMessage(msg) } } public onLevelLoad(level: number, newLevelSize: Optional<{rows: number, cols: number}>) { for (const h of this.subHandlers) { h.onLevelLoad && h.onLevelLoad(level, newLevelSize) } } public onLevelChange(level: number, cells: Optional, message: Optional) { for (const h of this.subHandlers) { h.onLevelChange && h.onLevelChange(level, cells, message) } } public onWin() { for (const h of this.subHandlers) { h.onWin && h.onWin() } } public async onSound(sound: Soundish) { for (const h of this.subHandlers) { h.onSound && h.onSound(sound) } } public onTick(changedCells: Set, checkpoint: Optional, hasAgain: boolean, a11yMessages: Array>) { for (const h of this.subHandlers) { h.onTick && h.onTick(changedCells, checkpoint, hasAgain, a11yMessages) } } public onPause() { for (const h of this.subHandlers) { h.onPause && h.onPause() } } public onResume() { for (const h of this.subHandlers) { h.onResume && h.onResume() } } // public onGameChange(data: GameData) { this.subHandlers.forEach(h => h.onGameChange && h.onGameChange(data)) } } export interface Engineish { setGame(code: string, level: number, checkpoint: Optional): void dispose(): void pause?(): void resume?(): void press?(dir: INPUT_BUTTON): void tick?(): void }