puzzlescript
Version:
Play PuzzleScript games in your terminal!
477 lines • 21.1 kB
JavaScript
"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