puzzlescript
Version:
Play PuzzleScript games in your terminal!
566 lines (493 loc) • 22.8 kB
text/typescript
import { HexColor, IColor } from '../models/colors'
import { GameData } from '../models/game'
import { GameSprite } from '../models/tile'
import { _flatten, Cellish, Optional } from '../util'
class CellColorCache {
private readonly cache: Map<string, IColor[][]>
constructor() {
this.cache = new Map()
}
public get(spritesToDraw: GameSprite[],
backgroundColor: Optional<IColor>,
spriteHeight: number,
spriteWidth: number) {
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
}
public clear() {
this.cache.clear()
}
}
// First Sprite one is on top.
// This caused a 2x speedup while rendering.
function collapseSpritesToPixels(spritesToDraw: GameSprite[],
backgroundColor: Optional<IColor>,
spriteHeight: number,
spriteWidth: number) {
if (spritesToDraw.length === 0) {
// Just draw the background
const spritePixels: IColor[][] = []
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 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
}
}
abstract class BaseUI {
public PIXEL_WIDTH: number // number of characters in the terminal used to represent a pixel
public PIXEL_HEIGHT: number
protected gameData: Optional<GameData>
protected renderedPixels: Array<Array<{hex: string, chars: string}>> // string is the hex code of the pixel
protected windowOffsetColStart: number
protected windowOffsetRowStart: number
protected windowOffsetWidth: Optional<number>
protected windowOffsetHeight: Optional<number>
protected isDumpingScreen: boolean
protected SPRITE_WIDTH: number
protected SPRITE_HEIGHT: number
protected hasVisualUi: boolean
private currentLevelCells: Optional<Cellish[][]>
private currentLevelMessage: Optional<string>
private readonly cellColorCache: CellColorCache
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
}
public destroy() {
this.gameData = null
this.currentLevelMessage = null
this.currentLevelCells = null
this.renderedPixels = []
this.cellColorCache.clear()
}
public onGameChange(gameData: 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
}
public getGameData() {
if (!this.gameData) {
throw new Error(`BUG: Game has not been specified yet`)
}
return this.gameData
}
public onLevelChange(level: number, cells: Optional<Cellish[][]>, message: Optional<string>) {
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
}
public 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
}
public debugRenderScreen() {
this.renderScreen(true)
}
public renderMessageScreen(message: string) {
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
}
public renderScreen(clearCaches: boolean, renderScreenDepth: number = 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)
}
}
public drawCells(cells: Iterable<Cellish>, dontRestoreCursor: boolean, renderScreenDepth: number = 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 = _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 = _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)
}
public /*for testing*/ getPixelsForCell(cell: Cellish) {
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
}
protected createMessageTextScreen(messageStr: string) {
const titleImage = [
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' ',
' X to continue ',
' ',
' '
]
function wordwrap(str: string, screenWidth: number) {
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
}
protected createMessageSprites(messageStr: string) {
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: Array<Array<Set<GameSprite>>> = []
for (const row of titleImage) {
const cellsRow: Array<Set<GameSprite>> = []
cells.push(cellsRow)
for (const char of row) {
const sprite = this.gameData.getLetterSprite(char)
cellsRow.push(new Set([sprite]))
}
}
return cells
}
protected abstract renderLevelScreen(levelRows: Cellish[][], renderScreenDepth: number): void
protected abstract setPixel(x: number, y: number, hex: string, fgHex: Optional<string>, chars: string): void
protected abstract checkIfCellCanBeDrawnOnScreen(cellStartX: number, cellStartY: number): boolean
protected cellPosToXY(cell: Cellish) {
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 }
}
protected abstract getMaxSize(): {columns: number, rows: number}
protected abstract drawCellsAfterRecentering(cells: Iterable<Cellish>, renderScreenDepth: number): void
protected clearScreen() {
this.renderedPixels = []
}
// Returns true if the window was moved (so we can re-render the screen)
private recenterPlayerIfNeeded(playerCell: Cellish, isOnScreen: boolean) {
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
}
}
export default BaseUI