UNPKG

puzzlescript

Version:

Play PuzzleScript games in your terminal!

477 lines 21.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const colors_1 = require("../models/colors"); const util_1 = require("../util"); class CellColorCache { constructor() { this.cache = new Map(); } get(spritesToDraw, backgroundColor, spriteHeight, spriteWidth) { const key = spritesToDraw.map((s) => s.getName()).join(' '); let ret = this.cache.get(key); if (!ret) { ret = collapseSpritesToPixels(spritesToDraw, backgroundColor, spriteHeight, spriteWidth); this.cache.set(key, ret); } return ret; } clear() { this.cache.clear(); } } // First Sprite one is on top. // This caused a 2x speedup while rendering. function collapseSpritesToPixels(spritesToDraw, backgroundColor, spriteHeight, spriteWidth) { if (spritesToDraw.length === 0) { // Just draw the background const spritePixels = []; for (let y = 0; y < spriteHeight; y++) { spritePixels[y] = spritePixels[y] || []; for (let x = 0; x < spriteWidth; x++) { // If this is the last sprite and nothing was found then use the game background color if (backgroundColor) { spritePixels[y][x] = backgroundColor; } } } return spritePixels; } // Record Code coverage if (process.env.NODE_ENV === 'development') { spritesToDraw[0].__incrementCoverage(); } if (spritesToDraw.length === 1) { return spritesToDraw[0].getPixels(spriteHeight, spriteWidth); } // If any of the pixels have alpha transparency then we have to build the image up, rather than building it dow const anySpriteHasAlpha = !!spritesToDraw.find((s) => s.hasAlpha()); if (anySpriteHasAlpha) { spritesToDraw = spritesToDraw.reverse(); const sprite = spritesToDraw[0].getPixels(spriteHeight, spriteWidth); spritesToDraw.slice(1).forEach((objectToDraw, spriteIndex) => { if (process.env.NODE_ENV === 'development') { objectToDraw.__incrementCoverage(); } const pixels = objectToDraw.getPixels(spriteHeight, spriteWidth); for (let y = 0; y < spriteHeight; y++) { sprite[y] = sprite[y] || []; for (let x = 0; x < spriteWidth; x++) { const pixel = pixels[y][x]; // try to pull it out of the current sprite if (pixel.hasAlpha() && !sprite[y][x].isTransparent()) { // Compute the transparency using the alpha channel const { r: oldR, g: oldG, b: oldB } = sprite[y][x].toRgb(); const { r: newR, g: newG, b: newB, a: alpha } = pixel.toRgb(); if (alpha !== null) { const r = Math.round(alpha * newR + (1 - alpha) * oldR); const g = Math.round(alpha * newG + (1 - alpha) * oldG); const b = Math.round(alpha * newB + (1 - alpha) * oldB); const rStr = `${r < 16 ? '0' : ''}${r.toString(16)}`; const gStr = `${g < 16 ? '0' : ''}${g.toString(16)}`; const bStr = `${b < 16 ? '0' : ''}${b.toString(16)}`; sprite[y][x] = new colors_1.HexColor(pixel.__source, `#${rStr}${gStr}${bStr}`); } else { throw new Error(`BUG: No alpha channel`); } } else { if (pixel && !pixel.isTransparent()) { sprite[y][x] = pixel; } } } } }); return sprite; } else { const sprite = spritesToDraw[0].getPixels(spriteHeight, spriteWidth); spritesToDraw.slice(1).forEach((objectToDraw, spriteIndex) => { if (process.env.NODE_ENV === 'development') { objectToDraw.__incrementCoverage(); } const pixels = objectToDraw.getPixels(spriteHeight, spriteWidth); for (let y = 0; y < spriteHeight; y++) { sprite[y] = sprite[y] || []; for (let x = 0; x < spriteWidth; x++) { const pixel = pixels[y][x]; // try to pull it out of the current sprite if ((!sprite[y][x] || sprite[y][x].isTransparent()) && pixel && !pixel.isTransparent()) { sprite[y][x] = pixel; } } } }); return sprite; } } class BaseUI { constructor() { this.cellColorCache = new CellColorCache(); this.renderedPixels = []; this.windowOffsetColStart = 0; this.windowOffsetRowStart = 0; this.isDumpingScreen = false; // defaults that get overridden later this.PIXEL_HEIGHT = 1; this.PIXEL_WIDTH = 2; this.SPRITE_HEIGHT = 5; this.SPRITE_WIDTH = 5; this.hasVisualUi = true; this.gameData = null; this.currentLevelMessage = null; this.currentLevelCells = null; this.windowOffsetWidth = null; this.windowOffsetHeight = null; } destroy() { this.gameData = null; this.currentLevelMessage = null; this.currentLevelCells = null; this.renderedPixels = []; this.cellColorCache.clear(); } onGameChange(gameData) { this.gameData = gameData; this.renderedPixels = []; this.cellColorCache.clear(); this.clearScreen(); // reset flickscreen and zoomscreen settings this.windowOffsetColStart = 0; this.windowOffsetRowStart = 0; this.windowOffsetWidth = null; this.windowOffsetHeight = null; if (this.gameData.metadata.flickscreen) { const { width, height } = this.gameData.metadata.flickscreen; this.windowOffsetWidth = width; this.windowOffsetHeight = height; } else if (this.gameData.metadata.zoomscreen) { const { width, height } = this.gameData.metadata.zoomscreen; this.windowOffsetWidth = width; this.windowOffsetHeight = height; } // Set the sprite width/height based on the 1st sprite (default is 5x5) // TODO: Loop until we find an actual sprite, not a single-color sprite const { spriteHeight, spriteWidth } = this.gameData.getSpriteSize(); this.SPRITE_HEIGHT = spriteHeight; this.SPRITE_WIDTH = spriteWidth; } getGameData() { if (!this.gameData) { throw new Error(`BUG: Game has not been specified yet`); } return this.gameData; } onLevelChange(level, cells, message) { if ((!cells && !message) || (cells && message)) { throw new Error(`BUG: Must provide either cells or a message (but not both)`); } this.currentLevelCells = cells; this.currentLevelMessage = message; } getCurrentLevelCells() { if (!this.currentLevelCells) { throw new Error(`BUG: There are no cells to render. Maybe it is a message level? Or no level has been set yet`); } return this.currentLevelCells; } debugRenderScreen() { this.renderScreen(true); } renderMessageScreen(message) { const screenWidth = 34; const screenHeight = 13; // re-center the screen so we can show the message // remember these values so we can restore them right after rendering the message // tslint:disable-next-line:no-this-assignment const { windowOffsetColStart, windowOffsetRowStart, windowOffsetHeight, windowOffsetWidth } = this; this.windowOffsetColStart = 0; this.windowOffsetRowStart = 0; this.windowOffsetHeight = screenHeight; this.windowOffsetWidth = screenWidth; this.clearScreen(); // if (this.engine) { // const sprites = this.createMessageSprites(message) // this.engine.setMessageLevel(sprites) // // this.renderScreen(false) // this.drawCellsAfterRecentering(_flatten(this.getCurrentLevelCells()), 0) // this.engine.restoreFromMessageLevel() // } this.windowOffsetColStart = windowOffsetColStart; this.windowOffsetRowStart = windowOffsetRowStart; this.windowOffsetHeight = windowOffsetHeight; this.windowOffsetWidth = windowOffsetWidth; } renderScreen(clearCaches, renderScreenDepth = 0) { if (!this.gameData) { throw new Error(`BUG: gameData was not set yet`); } if (this.currentLevelMessage) { this.renderMessageScreen(this.currentLevelMessage); } else if (this.currentLevelCells) { // Otherwise, the level is a Map so render the cells if (clearCaches) { this.cellColorCache.clear(); this.renderedPixels = []; } this.renderLevelScreen(this.currentLevelCells, renderScreenDepth); } } drawCells(cells, dontRestoreCursor, renderScreenDepth = 0) { if (!this.gameData) { throw new Error(`BUG: gameData was not set yet`); } // Sort of HACKy... If the player is not visible on the screen then we need to // move the screen so that they are visible. const allCells = (0, util_1._flatten)(this.getCurrentLevelCells()); const playerTile = this.gameData.getPlayer(); const playerCells = playerTile.getCellsThatMatch(allCells); if (playerCells.size === 1) { // if the screen can only show an even number of cells (eg 4) then this will oscillate indefinitely // So we limit the recursion to just a couple of recursions if (renderScreenDepth <= 1) { const playerCell = [...playerCells][0]; const { isOnScreen } = this.cellPosToXY(playerCell); if (this.recenterPlayerIfNeeded(playerCell, isOnScreen)) { // if we moved the screen then re-render the whole screen cells = (0, util_1._flatten)(this.getCurrentLevelCells()); } } // otherwise, keep rendering cells like normal } if (!this.hasVisualUi) { return; // no need to re-say the whole level (a11y) } this.drawCellsAfterRecentering(cells, renderScreenDepth); } getPixelsForCell(cell) { if (!this.gameData) { throw new Error(`BUG: gameData was not set yet`); } const spritesToDraw = cell.getSprites().filter((s) => !s.isTransparent()); // If there is a magic background object then rely on it last const magicBackgroundSprite = this.gameData.getMagicBackgroundSprite(); if (magicBackgroundSprite) { spritesToDraw.push(magicBackgroundSprite); } const pixels = this.cellColorCache.get(spritesToDraw, this.gameData.metadata.backgroundColor, this.SPRITE_HEIGHT, this.SPRITE_WIDTH); return pixels; } createMessageTextScreen(messageStr) { const titleImage = [ ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' ', ' X to continue ', ' ', ' ' ]; function wordwrap(str, screenWidth) { screenWidth = screenWidth || 75; const cut = true; if (!str) { return str; } const regex = '.{1,' + screenWidth + '}(\\s|$)' + (cut ? '|.{' + screenWidth + '}|.+$' : '|\\S+?(\\s|$)'); const ret = str.match(RegExp(regex, 'g')); if (ret) { return ret; } throw new Error(`BUG: Match did not work`); } const emptyLineStr = titleImage[9]; const xToContinueStr = titleImage[10]; titleImage[10] = emptyLineStr; const width = titleImage[0].length; const splitMessage = wordwrap(messageStr, titleImage[0].length); let offset = 5 - ((splitMessage.length / 2) | 0); // tslint:disable-line:no-bitwise if (offset < 0) { offset = 0; } const count = Math.min(splitMessage.length, 12); for (let i = 0; i < count; i++) { const m = splitMessage[i]; const row = offset + i; const messageLength = m.length; const lmargin = ((width - messageLength) / 2) | 0; // tslint:disable-line:no-bitwise // var rmargin = width-messageLength-lmargin; const rowtext = titleImage[row]; titleImage[row] = rowtext.slice(0, lmargin) + m + rowtext.slice(lmargin + m.length); } let endPos = 10; if (count >= 10) { if (count < 12) { endPos = count + 1; } else { endPos = 12; } } // if (quittingMessageScreen) { // titleImage[endPos]=emptyLineStr; // } else { titleImage[endPos] = xToContinueStr; // } return titleImage; } createMessageSprites(messageStr) { if (!this.gameData) { throw new Error(`BUG: gameData was not set yet`); } const titleImage = this.createMessageTextScreen(messageStr); // Now, convert the string array into cells const cells = []; for (const row of titleImage) { const cellsRow = []; cells.push(cellsRow); for (const char of row) { const sprite = this.gameData.getLetterSprite(char); cellsRow.push(new Set([sprite])); } } return cells; } cellPosToXY(cell) { const { colIndex, rowIndex } = cell; let isOnScreen = true; // can be set to false for many reasons let cellStartX = -1; let cellStartY = -1; if (this.windowOffsetHeight && this.windowOffsetWidth) { if (this.windowOffsetColStart > colIndex || this.windowOffsetRowStart > rowIndex || this.windowOffsetColStart + this.windowOffsetWidth <= colIndex || this.windowOffsetRowStart + this.windowOffsetHeight <= rowIndex) { // cell is off-screen isOnScreen = false; } } cellStartX = (colIndex - this.windowOffsetColStart) * this.SPRITE_WIDTH; cellStartY = (rowIndex - this.windowOffsetRowStart) * this.SPRITE_HEIGHT; /*pixels*/ if (isOnScreen) { isOnScreen = this.checkIfCellCanBeDrawnOnScreen(cellStartX, cellStartY); } if (cellStartX < 0 || cellStartY < 0) { isOnScreen = false; } return { isOnScreen, cellStartX, cellStartY }; } clearScreen() { this.renderedPixels = []; } // Returns true if the window was moved (so we can re-render the screen) recenterPlayerIfNeeded(playerCell, isOnScreen) { if (!this.gameData) { throw new Error(`BUG: gameData was not set yet`); } let boundingBoxLeft; let boundingBoxTop; let boundingBoxWidth; let boundingBoxHeight; const windowLeft = this.windowOffsetColStart; const windowTop = this.windowOffsetRowStart; let windowWidth; let windowHeight; const flickScreen = this.gameData.metadata.flickscreen; const zoomScreen = this.gameData.metadata.zoomscreen; // these are number of sprites that can fit on the terminal const { columns, rows } = this.getMaxSize(); const terminalWidth = Math.floor(columns / this.SPRITE_WIDTH / this.PIXEL_WIDTH); const terminalHeight = Math.floor(rows / this.SPRITE_HEIGHT / this.PIXEL_HEIGHT); if (flickScreen) { boundingBoxTop = playerCell.rowIndex - (playerCell.rowIndex % flickScreen.height); boundingBoxLeft = playerCell.colIndex - (playerCell.colIndex % flickScreen.width); boundingBoxHeight = flickScreen.height; boundingBoxWidth = flickScreen.width; } else { boundingBoxLeft = 0; boundingBoxTop = 0; boundingBoxHeight = this.getCurrentLevelCells().length; boundingBoxWidth = this.getCurrentLevelCells()[0].length; } if (zoomScreen) { windowHeight = Math.min(zoomScreen.height, terminalHeight); windowWidth = Math.min(zoomScreen.width, terminalWidth); } else { windowHeight = terminalHeight; windowWidth = terminalWidth; } // If the boundingbox is larger than the window then we need to apply the zoom // which means we need to pan whenever the player moves out of the middle 1/2 of // the screen. if (boundingBoxHeight <= windowHeight && boundingBoxWidth <= windowWidth) { // just ensure that the player is on the screen if (!isOnScreen) { this.windowOffsetColStart = boundingBoxLeft; this.windowOffsetRowStart = boundingBoxTop; return true; } } else { // Move the screen so that the player is centered* // Except when we are at one of the edges of the level/flickscreen // Check the left and then the top let didADirectionChange = false; if (boundingBoxWidth > windowWidth) { if (windowLeft + Math.round(windowWidth / 4) > playerCell.colIndex || windowLeft + Math.round(windowWidth * 3 / 4) <= playerCell.colIndex) { let newWindowLeft = playerCell.colIndex - Math.floor(windowWidth / 2); // Check the near sides of the bounding box (left) newWindowLeft = Math.max(newWindowLeft, boundingBoxLeft); // Check the far sides of the bounding box (right) if (newWindowLeft + windowWidth > boundingBoxLeft + boundingBoxWidth) { newWindowLeft = boundingBoxLeft + boundingBoxWidth - windowWidth; } if (newWindowLeft !== this.windowOffsetColStart) { this.windowOffsetColStart = newWindowLeft; didADirectionChange = true; } } } // This is copy/pasta'd from above but adjusted for Top instead of Left if (boundingBoxHeight > windowHeight) { if (windowTop + Math.round(windowHeight / 4) > playerCell.rowIndex || windowTop + Math.round(windowHeight * 3 / 4) <= playerCell.rowIndex) { let newWindowTop = playerCell.rowIndex - Math.floor(windowHeight / 2); // Check the near sides of the bounding box (top) newWindowTop = Math.max(newWindowTop, boundingBoxTop); // Check the far sides of the bounding box (bottom) if (newWindowTop + windowHeight > boundingBoxTop + boundingBoxHeight) { newWindowTop = boundingBoxTop + boundingBoxHeight - windowHeight; } // Only recenter the axis that moved to be out-of-center // Use Math.abs() because an even number of cells visible // (e.g. 4) will cause the screen to clicker back and forth if (newWindowTop !== this.windowOffsetRowStart) { this.windowOffsetRowStart = newWindowTop; didADirectionChange = true; } } } if (!didADirectionChange) { // cell is within the middle of the window. // just ensure that the player is on the screen if (!isOnScreen) { this.windowOffsetColStart = boundingBoxLeft; this.windowOffsetRowStart = boundingBoxTop; return true; } } return didADirectionChange; } return false; } } exports.default = BaseUI; //# sourceMappingURL=base.js.map