import { lookupColorPalette } from '../colors' import { CollisionLayer } from '../models/collisionLayer' import { HexColor, TransparentColor } from '../models/colors' import { GameData } from '../models/game' import { Dimension, GameMetadata } from '../models/metadata' import { ISimpleBracket, SimpleBracket, SimpleEllipsisBracket, SimpleNeighbor, SimpleRule, SimpleRuleGroup, SimpleRuleLoop, SimpleTileWithModifier } from '../models/rule' import { GameLegendTileAnd, GameLegendTileOr, GameLegendTileSimple, GameSprite, GameSpritePixels, GameSpriteSingleColor, IGameTile } from '../models/tile' import { WinConditionOn, WinConditionSimple } from '../models/winCondition' import { ICacheable, Optional, RULE_DIRECTION, RULE_DIRECTION_RELATIVE, RULE_DIRECTION_WITH_RELATIVE } from '../util' import * as ast from './astTypes' // Shorthand because the type signatures are long type AST_Tile = ast.TileWithModifier type AST_Neighbor = ast.Neighbor type AST_BasicRule = ast.SimpleRule, ast.Command> type AST_Rule = ast.Rule< ast.RuleGroup< ast.SimpleRule< ast.Bracket, ast.Command > >, ast.SimpleRule< ast.Bracket, ast.Command>, ast.Bracket, ast.Command> const RULE_DIRECTION_LIST = [ RULE_DIRECTION.UP, RULE_DIRECTION.DOWN, RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT ] const RULE_DIRECTION_SET: Set = new Set(RULE_DIRECTION_LIST) function removeNulls(ary: Array>) { // return ary.filter(a => !!a) // TypeScript-friendly version const ret: T[] = [] for (const item of ary) { if (item) { ret.push(item) } } return ret } function cacheSetAndGet(cache: Map, obj: A) { const key = obj.toKey() if (!cache.has(key)) { cache.set(key, obj) } return cache.get(key) as A } function relativeDirectionToAbsolute(currentDirection: RULE_DIRECTION, relativeModifier: RULE_DIRECTION_RELATIVE) { let currentDir switch (currentDirection) { case RULE_DIRECTION.RIGHT: currentDir = 0 break case RULE_DIRECTION.UP: currentDir = 1 break case RULE_DIRECTION.LEFT: currentDir = 2 break case RULE_DIRECTION.DOWN: currentDir = 3 break default: throw new Error(`BUG! Invalid rule direction "${currentDirection}`) } switch (relativeModifier) { case RULE_DIRECTION_RELATIVE.RELATIVE_RIGHT: currentDir += 0 break case RULE_DIRECTION_RELATIVE.RELATIVE_UP: currentDir += 1 break case RULE_DIRECTION_RELATIVE.RELATIVE_LEFT: currentDir += 2 break case RULE_DIRECTION_RELATIVE.RELATIVE_DOWN: currentDir += 3 break default: throw new Error(`BUG! invalid relative direction "${relativeModifier}"`) } switch (currentDir % 4) { case 0: return RULE_DIRECTION.RIGHT case 1: return RULE_DIRECTION.UP case 2: return RULE_DIRECTION.LEFT case 3: return RULE_DIRECTION.DOWN default: throw new Error(`BUG! Incorrectly computed rule direction "${currentDirection}" "${relativeModifier}"`) } } export class AstBuilder { private readonly code: string private readonly tileCache: Map private readonly soundCache: Map> constructor(code: string) { this.code = code this.tileCache = new Map() this.soundCache = new Map() } public build(root: ast.IASTGame) { const source = this.toSource({ _sourceOffset: 0 }) const metadata = new GameMetadata() root.metadata.forEach((pair) => { let value if (typeof pair.value === 'object' && pair.value.type) { switch (pair.value.type) { case ast.COLOR_TYPE.HEX8: case ast.COLOR_TYPE.HEX6: case ast.COLOR_TYPE.NAME: { const v = pair.value value = this.buildColor(v, metadata.colorPalette) } break case 'WIDTH_AND_HEIGHT': { const v = pair.value const v2 = v value = new Dimension(v2.width, v2.height) } break default: throw new Error(`BUG: Invalid type at this point in time: ${pair.value}`) } } else { value = pair.value } metadata._setValue(pair.type, value) }) const sprites = root.sprites.map((n) => this.buildSprite(n, metadata.colorPalette)) // assign an index to each GameSprite sprites.forEach((sprite, index) => { sprite.allSpritesBitSetIndex = index }) // Load the legend items up (used in Rules and Levels later on) const legendItems = root.legendItems.map((n) => this.buildLegendItem(n)) const sounds = root.sounds.map((n) => this.buildSound(n)) const collisionLayers = root.collisionLayers.map((n) => this.buildCollisionLayer(n)) const simpleRules = this.buildSimpleRules(root.rules) const winConditions = root.winConditions.map((n) => this.buildWinConditon(n)) const levels = root.levels.map((n) => this.buildLevel(n)) return new GameData(source, root.title, metadata, sprites, legendItems, sounds, collisionLayers, simpleRules, winConditions, levels) } private buildSprite(node: ast.Sprite, colorPalette: Optional) { let ret: GameSprite if (node.type === ast.SPRITE_TYPE.WITH_PIXELS) { const source = this.toSource(node) const colors = node.colors.map((n) => this.buildColor(n, colorPalette)) const pixels = node.pixels.map((row) => { return row.map((col) => { if (col === '.') { return new TransparentColor(source) } else { return colors[col] || new TransparentColor(source) } }) }) // Pixel colors are 0-indexed. ret = new GameSpritePixels(source, node.name, node.mapChar, pixels) } else { ret = new GameSpriteSingleColor(this.toSource(node), node.name, node.mapChar, node.colors.map((n) => this.buildColor(n, colorPalette))) } this.cacheAdd(node.name, ret) if (node.mapChar) { this.cacheAdd(node.mapChar, ret) } return ret } private buildColor(node: ast.IColor, colorPalette: Optional) { const source = this.toSource(node) const currentColorPalette = colorPalette || 'arnecolors' switch (node.type) { case ast.COLOR_TYPE.HEX8: case ast.COLOR_TYPE.HEX6: return new HexColor(source, node.value) case ast.COLOR_TYPE.NAME: if (node.value.toUpperCase() === 'TRANSPARENT') { return new TransparentColor(source) } else { // Look up the color const hex = lookupColorPalette(currentColorPalette, node.value) if (hex) { return new HexColor(source, hex) } else { return new TransparentColor(source) } } default: throw new Error(`Unsupported type ${node}`) } } private buildLegendItem(node: ast.LegendItem) { const source = this.toSource(node) switch (node.type) { case ast.TILE_TYPE.SIMPLE: if (!node.tile) { throw new Error(`BUG!!!!!!`) } { const ret = new GameLegendTileSimple(source, node.name, this.cacheGet(node.tile) as GameSprite) this.cacheAdd(node.name, ret) return ret } case ast.TILE_TYPE.AND: if (!node.tiles) { throw new Error(`BUG!!!!!!`) } { const ret = new GameLegendTileAnd(source, node.name, node.tiles.map((n) => this.cacheGet(n))) this.cacheAdd(node.name, ret) return ret } case ast.TILE_TYPE.OR: if (!node.tiles) { throw new Error(`BUG!!!!!!`) } { const ret = new GameLegendTileOr(source, node.name, node.tiles.map((n) => this.cacheGet(n))) this.cacheAdd(node.name, ret) return ret } default: throw new Error(`Unsupported type ${node}`) } } private buildCollisionLayer(node: ast.CollisionLayer) { const source = this.toSource(node) return new CollisionLayer(source, node.tiles.map((n) => this.cacheGet(n))) } private buildSound(node: ast.SoundItem): ast.SoundItem { switch (node.type) { case 'SOUND_SFX': this.soundCacheAdd(node.soundEffect, node) return node case 'SOUND_WHEN': return node case 'SOUND_SPRITE_MOVE': case 'SOUND_SPRITE_DIRECTION': case 'SOUND_SPRITE_EVENT': return { ...node, sprite: this.cacheGet(node.sprite) } default: throw new Error(`Unsupported type ${node}`) } } private buildCommand(node: ast.Command): Optional>> { switch (node.type) { case ast.COMMAND_TYPE.SFX: if (!this.soundCacheHas(node.sound)) { return null } const sound = this.soundCacheGet(node.sound) return { ...node, sound } case ast.COMMAND_TYPE.AGAIN: case ast.COMMAND_TYPE.CANCEL: case ast.COMMAND_TYPE.MESSAGE: case ast.COMMAND_TYPE.CHECKPOINT: case ast.COMMAND_TYPE.RESTART: case ast.COMMAND_TYPE.WIN: return node default: throw new Error(`Unsupported type ${node}`) } } private buildWinConditon(node: ast.WinCondition) { const source = this.toSource(node) switch (node.type) { case ast.WIN_CONDITION_TYPE.ON: return new WinConditionOn(source, node.qualifier, this.cacheGet(node.tile), this.cacheGet(node.onTile)) case ast.WIN_CONDITION_TYPE.SIMPLE: return new WinConditionSimple(source, node.qualifier, this.cacheGet(node.tile)) default: throw new Error(`Unsupported type ${node}`) } } private buildLevel(node: ast.Level) { switch (node.type) { case ast.LEVEL_TYPE.MESSAGE: return node case ast.LEVEL_TYPE.MAP: return { ...node, cells: node.cells.map((row) => row.map((cell) => this.cacheGet(cell))) } default: throw new Error(`Unsupported type ${node}`) } } private buildSimpleRules(rules: AST_Rule[]) { // Simplify the rules by de-duplicating them const ruleCache = new Map() const bracketCache = new Map() const neighborCache = new Map() const tileCache = new Map() const simpleRules = rules.map((n) => this.simplifyRule(n, ruleCache, bracketCache, neighborCache, tileCache)) return simpleRules } private simplifyRule(node: AST_Rule, ruleCache: Map, bracketCache: Map, neighborCache: Map, tileCache: Map): SimpleRuleGroup { const source = this.toSource(node) switch (node.type) { case ast.RULE_TYPE.LOOP: { const subRules = node.rules.map((n) => this.simplifyRule(n, ruleCache, bracketCache, neighborCache, tileCache)) return new SimpleRuleLoop(source, false/*isRandom*/, subRules) } case ast.RULE_TYPE.GROUP: // Extra checks to make TypeScript happy if (node.rules[0]) { const firstRule = node.rules[0] const isRandom = !!firstRule.isRandom const subRules = node.rules.map((n) => this.simplifyRule(n, ruleCache, bracketCache, neighborCache, tileCache)) // if (rules.length === 1) { // return rules[0] // } return new SimpleRuleGroup(source, isRandom, subRules) } throw new Error(`BUG!!!!!!`) case ast.RULE_TYPE.SIMPLE: { /** * Expands this Rule into multiple `SimpleRule` objects. * * For example, `HORIZONTAL [ > player ] -> [ < crate ]` gets expanded to the following `SimpleRule`s: * * - `LEFT [ LEFT player ] -> [ RIGHT crate ]` * - `RIGHT [ RIGHT player ] -> [ LEFT crate ]` * * The `SimpleRule`s only have absolute directions * rather than the relative ones specified in the original game code. */ const isRandom = !!node.isRandom const conditions = node.conditions const actions = node.actions // Check if valid if (conditions.length !== actions.length && actions.length !== 0) { throw new Error(`Left side has "${conditions.length}" conditions and right side has "${actions.length}" actions!`) } if (conditions.length === actions.length) { // do nothing } else if (actions.length !== 0) { throw new Error(`Invalid Rule. The number of brackets on the right must match the structure of the left hand side or be 0`) } const simpleRules = this.rule_convertToMultiple(node).map((r) => this.rule_toSimple(r, ruleCache, bracketCache, neighborCache, tileCache)) // If the brackets are all the same object then that means we can just output 1 rule // (the brackets don't have any directions. Otherwise they would not have been // deduplicated as part of the .toKey() and cacheGetAndSet) const isDuplicate = simpleRules.length === 1 || (!node.isRandom && simpleRules[1] && simpleRules[0].canCollapseBecauseBracketsMatch(simpleRules[1])) if (isDuplicate) { simpleRules[0].subscribeToCellChanges() // we still need it to be in a RuleGroup // so the Rule can be evaluated multiple times (not just once) return new SimpleRuleGroup(source, isRandom, [simpleRules[0]]) } else { for (const rule of simpleRules) { rule.subscribeToCellChanges() } return new SimpleRuleGroup(source, isRandom, simpleRules) } } default: throw new Error(`Unsupported type ${node}`) } } private rule_toSimple(node: AST_BasicRule, ruleCache: Map, bracketCache: Map, neighborCache: Map, tileCache: Map) { const source = this.toSource(node) const directions = this.rule_getDirectionModifiers(node) const commands = removeNulls(node.commands.map((n) => this.buildCommand(n))) if (directions.length !== 1) { throw new Error(`BUG: should have exactly 1 direction by now but found the following: "${directions}"`) } // Check if the condition matches the action. If so, we can simplify evaluation. const conditionBrackets = node.conditions.map((x) => this.bracket_toSimple(x, directions[0], ruleCache, bracketCache, neighborCache, tileCache)) const actionBrackets = node.actions.map((x) => this.bracket_toSimple(x, directions[0], ruleCache, bracketCache, neighborCache, tileCache)) for (let index = 0; index < conditionBrackets.length; index++) { const action = actionBrackets[index] // Skip rules with no action bracket `[ > Player ] -> CHECKPOINT` if (!action) { continue } } return cacheSetAndGet(ruleCache, new SimpleRule(source, conditionBrackets, actionBrackets, commands, node.isLate, node.isRigid, node.debugFlag)) } private rule_convertToMultiple(node: AST_BasicRule) { let rulesToConvert = [] let convertedRules: AST_BasicRule[] = [] for (const direction of this.rule_getDirectionModifiers(node)) { const expandedDirection = this.rule_clone(node, direction, null, null) rulesToConvert.push(expandedDirection) } const expandModifiers = new Map() expandModifiers.set(ast.RULE_MODIFIER.HORIZONTAL, [RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT]) expandModifiers.set(ast.RULE_MODIFIER.VERTICAL, [RULE_DIRECTION.UP, RULE_DIRECTION.DOWN]) expandModifiers.set(ast.RULE_MODIFIER.MOVING, [RULE_DIRECTION.UP, RULE_DIRECTION.DOWN, RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT, RULE_DIRECTION.ACTION]) let didExpandRulesToConvert do { didExpandRulesToConvert = false for (const rule of rulesToConvert) { let didExpand = false const direction = this.rule_getDirectionModifiers(rule)[0] if (this.rule_getDirectionModifiers(rule).length !== 1) { throw new Error(`BUG: should have already expanded the rule to only contian one direction`) } for (const [nameToExpand, variations] of expandModifiers) { if (this.rule_hasModifier(rule, nameToExpand)) { for (const variation of variations) { convertedRules.push(this.rule_clone(rule, direction, nameToExpand, variation)) didExpand = true didExpandRulesToConvert = true } } } if (!didExpand) { // Try expanding PARALLEL and ORTHOGONAL (since they depend on the rule direction) let perpendiculars let parallels switch (direction) { case RULE_DIRECTION.UP: case RULE_DIRECTION.DOWN: perpendiculars = [RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT] parallels = [RULE_DIRECTION.UP, RULE_DIRECTION.DOWN] break case RULE_DIRECTION.LEFT: case RULE_DIRECTION.RIGHT: perpendiculars = [RULE_DIRECTION.UP, RULE_DIRECTION.DOWN] parallels = [RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT] break default: throw new Error(`BUG: There must be some direction`) } if (perpendiculars && parallels) { const orthoParallels = [ { nameToExpand: ast.RULE_MODIFIER.ORTHOGONAL, variations: perpendiculars }, { nameToExpand: ast.RULE_MODIFIER.PERPENDICULAR, variations: perpendiculars }, { nameToExpand: ast.RULE_MODIFIER.PARALLEL, variations: parallels } ] for (const { nameToExpand, variations } of orthoParallels) { if (this.rule_hasModifier(rule, nameToExpand)) { for (const variation of variations) { convertedRules.push(this.rule_clone(rule, direction, nameToExpand, variation)) didExpand = true didExpandRulesToConvert = true } } } } } // If nothing was expanded and this is the current rule // then just keep it if (!didExpand) { convertedRules.push(rule) } } rulesToConvert = convertedRules convertedRules = [] } while (didExpandRulesToConvert) return rulesToConvert } private rule_clone(node: AST_BasicRule, direction: RULE_DIRECTION, nameToExpand: Optional, newName: Optional) { const conditions = node.conditions.map((bracket) => this.bracket_clone(bracket, direction, nameToExpand, newName)) const actions = node.actions.map((bracket) => this.bracket_clone(bracket, direction, nameToExpand, newName)) // retain LATE and RIGID but discard the rest of the modifiers let directionModifier switch (direction) { case RULE_DIRECTION.UP: directionModifier = ast.RULE_MODIFIER.UP break case RULE_DIRECTION.DOWN: directionModifier = ast.RULE_MODIFIER.DOWN break case RULE_DIRECTION.LEFT: directionModifier = ast.RULE_MODIFIER.LEFT break case RULE_DIRECTION.RIGHT: directionModifier = ast.RULE_MODIFIER.RIGHT break default: throw new Error(`BUG: Invalid direction "${direction}"`) } return { ...node, directions: [directionModifier], conditions, actions } } private rule_getDirectionModifiers(node: AST_BasicRule) { // Convert HORIZONTAL and VERTICAL to 2: if (node.directions.indexOf(ast.RULE_MODIFIER.HORIZONTAL) >= 0) { return [RULE_DIRECTION.LEFT, RULE_DIRECTION.RIGHT] } if (node.directions.indexOf(ast.RULE_MODIFIER.VERTICAL) >= 0) { return [RULE_DIRECTION.UP, RULE_DIRECTION.DOWN] } const directions = node.directions.filter((m) => RULE_DIRECTION_SET.has(m)).map((d) => { switch (d) { case ast.RULE_MODIFIER.UP: return RULE_DIRECTION.UP case ast.RULE_MODIFIER.DOWN: return RULE_DIRECTION.DOWN case ast.RULE_MODIFIER.LEFT: return RULE_DIRECTION.LEFT case ast.RULE_MODIFIER.RIGHT: return RULE_DIRECTION.RIGHT default: throw new Error(`BUG: Invalid rule direction "${d}"`) } }) if (directions.length === 0) { return RULE_DIRECTION_LIST } else { return directions } } private rule_hasModifier(node: AST_BasicRule, modifier: ast.RULE_MODIFIER) { for (const bracket of node.conditions) { for (const neighbor of this.rule_getAllBracketNeighbors(bracket)) { for (const t of neighbor.tileWithModifiers) { if (t.direction as string === modifier) { // HACK: cast to string return true } } } } return false } private rule_getAllBracketNeighbors(node: ast.Bracket) { switch (node.type) { case ast.BRACKET_TYPE.SIMPLE: return node.neighbors case ast.BRACKET_TYPE.ELLIPSIS: return [...node.beforeNeighbors, ...node.afterNeighbors] default: throw new Error(`Unsupported type ${node}`) } } private bracket_clone(node: ast.Bracket, direction: RULE_DIRECTION, nameToExpand: Optional, newName: Optional): ast.Bracket { switch (node.type) { case ast.BRACKET_TYPE.SIMPLE: const neighbors = node.neighbors.map((n) => this.neighbor_clone(n, direction, nameToExpand, newName)) return { ...node, neighbors } case ast.BRACKET_TYPE.ELLIPSIS: const beforeNeighbors = node.beforeNeighbors.map((n) => this.neighbor_clone(n, direction, nameToExpand, newName)) const afterNeighbors = node.afterNeighbors.map((n) => this.neighbor_clone(n, direction, nameToExpand, newName)) return { ...node, beforeNeighbors, afterNeighbors } default: throw new Error(`Unsupported type ${node}`) } } private neighbor_clone(node: AST_Neighbor, direction: RULE_DIRECTION, nameToExpand: Optional, newName: Optional): AST_Neighbor { return { ...node, tileWithModifiers: node.tileWithModifiers.map((t) => this.tile_clone(t, direction, nameToExpand, newName)) } } private tile_clone(node: AST_Tile, direction: RULE_DIRECTION, nameToExpand: Optional, newName: Optional): AST_Tile { switch (node.direction) { case RULE_DIRECTION_RELATIVE.RELATIVE_UP: case RULE_DIRECTION_RELATIVE.RELATIVE_DOWN: case RULE_DIRECTION_RELATIVE.RELATIVE_LEFT: case RULE_DIRECTION_RELATIVE.RELATIVE_RIGHT: const modifier = relativeDirectionToAbsolute(direction, node.direction) return { ...node, direction: modifier } case nameToExpand: return { ...node, direction: newName } case RULE_DIRECTION.UP: case RULE_DIRECTION.DOWN: case RULE_DIRECTION.LEFT: case RULE_DIRECTION.RIGHT: case RULE_DIRECTION.ACTION: case RULE_DIRECTION.STATIONARY: case RULE_DIRECTION.RANDOMDIR: case null: case undefined: return node default: return node // throw new Error(`BUG: Unsupported tile direction ${JSON.stringify(node)}`) } } private bracket_toSimple(node: ast.Bracket, direction: RULE_DIRECTION, ruleCache: Map, bracketCache: Map, neighborCache: Map, tileCache: Map) { const source = this.toSource(node) switch (node.type) { case ast.BRACKET_TYPE.SIMPLE: const neighbors = node.neighbors.map((x) => this.neighbor_toSimple(x, neighborCache, tileCache)) return cacheSetAndGet(bracketCache, new SimpleBracket(source, direction, neighbors, node.debugFlag)) case ast.BRACKET_TYPE.ELLIPSIS: const beforeEllipsis = node.beforeNeighbors.map((x) => this.neighbor_toSimple(x, neighborCache, tileCache)) const afterEllipsis = node.afterNeighbors.map((x) => this.neighbor_toSimple(x, neighborCache, tileCache)) return cacheSetAndGet(bracketCache, new SimpleEllipsisBracket(source, direction, beforeEllipsis, afterEllipsis, node.debugFlag)) default: throw new Error(`Unsupported type ${node}`) } } private neighbor_toSimple(node: AST_Neighbor, neighborCache: Map, tileCache: Map) { const source = this.toSource(node) // Collapse duplicate tiles into one. // e.g. Cyber-Lasso has the following rule: // ... -> [ ElectricFloor Powered no ElectricFloor Claimed ] // // ElectricFloor occurs twice (one is negated) // We keep the first and remove the rest const tilesMap = new Map() for (const t of node.tileWithModifiers) { if (!tilesMap.has(t.tile)) { tilesMap.set(t.tile, t) } } const tileWithModifiers = [...tilesMap.values()] const simpleTilesWithModifier = new Set(removeNulls(tileWithModifiers.map((x) => this.tile_toSimple(x, tileCache)))) return cacheSetAndGet(neighborCache, new SimpleNeighbor(source, simpleTilesWithModifier, node.debugFlag)) } private tile_toSimple(node: AST_Tile, tileCache: Map) { const source = this.toSource(node) // Some games mistakenly use SFX# in a bracket when the SFX should be in the commands list after the brackets if (!this.cacheHas(node.tile)) { return null } const tile = this.cacheGet(node.tile) let direction switch (node.direction) { case RULE_DIRECTION_RELATIVE.RELATIVE_UP: case RULE_DIRECTION_RELATIVE.RELATIVE_DOWN: case RULE_DIRECTION_RELATIVE.RELATIVE_LEFT: case RULE_DIRECTION_RELATIVE.RELATIVE_RIGHT: throw new Error(`BUG: Relative directions should have been resolved by now`) default: direction = node.direction || null // could be undefined (causes problems when evaluating) } return cacheSetAndGet(tileCache, new SimpleTileWithModifier(source, node.isNegated, node.isRandom, direction, tile, node.debugFlag)) } private toSource(node: ast.IASTNode) { return { code: this.code, sourceOffset: node._sourceOffset } } private cacheHas(name: string) { return this.tileCache.has(name.toLowerCase()) } private cacheAdd(name: string, value: IGameTile) { if (this.tileCache.has(name.toLowerCase())) { throw new Error(`BUG??? duplicate definition of ${name}`) } this.tileCache.set(name.toLowerCase(), value) } private cacheGet(name: string) { const value = this.tileCache.get(name.toLowerCase()) if (value) { return value } else { throw new Error(`BUG: Could not find tile named ${name}`) } } private soundCacheAdd(name: string, value: ast.SoundItem) { if (this.soundCache.has(name.toLowerCase())) { throw new Error(`BUG??? duplicate definition of ${name}`) } this.soundCache.set(name.toLowerCase(), value) } private soundCacheGet(name: string) { const value = this.soundCache.get(name.toLowerCase()) if (value) { return value } else { throw new Error(`BUG: Could not find sound named ${name}`) } } private soundCacheHas(name: string) { return this.soundCache.has(name.toLowerCase()) } }