import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators"; import { brickCenterX, brickCenterY, countBricksAbove, countBricksBelow, currentLevelInfo, isTelekinesisActive, isYoyoActive, max_levels, } from "./game_utils"; import { colorString, GameState } from "./types"; import { t } from "./i18n/i18n"; import { gameState, lastMeasuredFPS } from "./game"; import { isOptionOn } from "./options"; export const gameCanvas = document.getElementById("game") as HTMLCanvasElement; export const ctx = gameCanvas.getContext("2d", { alpha: false, }) as CanvasRenderingContext2D; export const bombSVG = document.createElement("img"); bombSVG.src = "data:image/svg+xml;base64," + btoa(` `); bombSVG.onload = () => (gameState.needsRender = true); export const background = document.createElement("img"); background.onload = () => (gameState.needsRender = true); export const backgroundCanvas = document.createElement("canvas"); export function render(gameState: GameState) { if (!gameState.readyToRender) return; const level = currentLevelInfo(gameState); const hasCombo = gameState.combo > baseCombo(gameState); const { width, height } = gameCanvas; if (!width || !height) return; if (gameState.currentLevel || gameState.levelTime) { menuLabel.innerText = gameState.loop ? t("play.current_lvl_loop", { level: gameState.currentLevel + 1, max: max_levels(gameState), loop: gameState.loop, }) : t("play.current_lvl", { level: gameState.currentLevel + 1, max: max_levels(gameState), }); } else { menuLabel.innerText = t("play.menu_label"); } const catchRate = gameState.levelSpawnedCoins ? (gameState.levelSpawnedCoins - gameState.levelLostCoins) / gameState.levelSpawnedCoins : 1; scoreDisplay.innerHTML = (isOptionOn("show_fps") ? ` ${lastMeasuredFPS} FPS / ` : "") + (isOptionOn("show_stats") ? ` 0.9 && "good") || ""}" data-tooltip="${t("play.stats.coins_catch_rate")}"> ${Math.floor(catchRate * 100)}% / ${Math.ceil(gameState.levelTime / 1000)}s / ${gameState.levelWallBounces} B / ${gameState.levelMisses} M / ` : "") + `$${gameState.score}`; scoreDisplay.className = gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : ""; // Clear if (!isOptionOn("basic") && !level.color && level.svg) { // Without this the light trails everything ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = 1; ctx.fillStyle = "#000"; ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "screen"; ctx.globalAlpha = 0.6; forEachLiveOne(gameState.coins, (coin) => { drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y); }); gameState.balls.forEach((ball) => { drawFuzzyBall( ctx, gameState.ballsColor, gameState.ballSize * 2, ball.x, ball.y, ); }); ctx.globalAlpha = 0.5; gameState.bricks.forEach((color, index) => { if (!color) return; const x = brickCenterX(gameState, index), y = brickCenterY(gameState, index); drawFuzzyBall( ctx, color == "black" ? "#666" : color, gameState.brickWidth, x, y, ); }); ctx.globalAlpha = 1; forEachLiveOne(gameState.particles, (flash) => { const { x, y, time, color, size, duration } = flash; const elapsed = gameState.levelTime - time; ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); drawFuzzyBall(ctx, color, size * 3, x, y); }); // Decides how brights the bg black parts can get ctx.globalAlpha = 0.2; ctx.globalCompositeOperation = "multiply"; ctx.fillStyle = "black"; ctx.fillRect(0, 0, width, height); // Decides how dark the background black parts are when lit (1=black) ctx.globalAlpha = 0.8; ctx.globalCompositeOperation = "multiply"; if (level.svg && background.width && background.complete) { if (backgroundCanvas.title !== level.name) { backgroundCanvas.title = level.name; backgroundCanvas.width = gameState.canvasWidth; backgroundCanvas.height = gameState.canvasHeight; const bgctx = backgroundCanvas.getContext( "2d", ) as CanvasRenderingContext2D; bgctx.fillStyle = level.color || "#000"; bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight); if (gameState.perks.clairvoyant >= 3) { const pageSource = document.body.innerHTML.replace(/\s+/gi, ""); const lineWidth = Math.ceil(gameState.canvasWidth / 15); const lines = Math.ceil(gameState.canvasHeight / 20); const chars = lineWidth * lines; let start = Math.ceil(Math.random() * (pageSource.length - chars)); for (let i = 0; i < lines; i++) { bgctx.fillStyle = "white"; bgctx.font = "20px Courier"; bgctx.fillText( pageSource.slice( start + i * lineWidth, start + (i + 1) * lineWidth, ), 0, i * 20, gameState.canvasWidth, ); } } else { const pattern = ctx.createPattern(background, "repeat"); if (pattern) { bgctx.fillStyle = pattern; bgctx.fillRect(0, 0, width, height); } } } ctx.drawImage(backgroundCanvas, 0, 0); } else { // Background not loaded yes ctx.fillStyle = "#000"; ctx.fillRect(0, 0, width, height); } } else { ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = level.color || "#000"; ctx.fillRect(0, 0, width, height); forEachLiveOne(gameState.particles, (flash) => { const { x, y, time, color, size, duration } = flash; const elapsed = gameState.levelTime - time; ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); drawBall(ctx, color, size, x, y); }); } ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5; const shaked = lastExplosionDelay < 200 && !isOptionOn("basic"); if (shaked) { const amplitude = ((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay; ctx.translate( Math.sin(Date.now()) * amplitude, Math.sin(Date.now() + 36) * amplitude, ); } if (gameState.perks.bigger_explosions && !isOptionOn("basic") && shaked) { gameCanvas.style.filter = "brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")"; } else { gameCanvas.style.filter = ""; } // Coins ctx.globalAlpha = 1; forEachLiveOne(gameState.coins, (coin) => { ctx.globalCompositeOperation = "source-over"; // ctx.globalCompositeOperation = // coin.color === "gold" || level.color ? "source-over" : "screen"; drawCoin( ctx, coin.color, coin.size, coin.x, coin.y, (hasCombo && gameState.perks.asceticism && "red") || (coin.color === "gold" && "gold") || gameState.puckColor, coin.a, ); }); // Black shadow around balls if (!isOptionOn("basic")) { ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20); gameState.balls.forEach((ball) => { drawBall( ctx, level.color || "#000", gameState.ballSize * 6, ball.x, ball.y, ); }); } ctx.globalCompositeOperation = "source-over"; renderAllBricks(); ctx.globalCompositeOperation = "screen"; forEachLiveOne(gameState.lights, (flash) => { const { x, y, time, color, size, duration } = flash; const elapsed = gameState.levelTime - time; ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2) * 0.5; drawBrick(ctx, color, x, y, -1, gameState.perks.clairvoyant >= 2); }); ctx.globalCompositeOperation = "screen"; forEachLiveOne(gameState.texts, (flash) => { const { x, y, time, color, size, duration } = flash; const elapsed = gameState.levelTime - time; ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); ctx.globalCompositeOperation = "source-over"; drawText(ctx, flash.text, color, size, x, y - elapsed / 10); }); forEachLiveOne(gameState.particles, (particle) => { const { x, y, time, color, size, duration } = particle; const elapsed = gameState.levelTime - time; ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); ctx.globalCompositeOperation = "screen"; drawBall(ctx, color, size, x, y); drawFuzzyBall(ctx, color, size, x, y); }); if (gameState.perks.extra_life) { ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; ctx.fillStyle = gameState.puckColor; for (let i = 0; i < gameState.perks.extra_life; i++) { ctx.fillRect( gameState.perks.unbounded ? 0 : gameState.offsetXRoundedDown, gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, gameState.perks.unbounded ? gameState.canvasWidth : gameState.gameZoneWidthRoundedUp, 1, ); } } ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; gameState.balls.forEach((ball) => { const drawingColor = gameState.ballsColor; // The white border around is to distinguish colored balls from coins/bg drawBall( ctx, drawingColor, gameState.ballSize, ball.x, ball.y, gameState.puckColor, ); if (isTelekinesisActive(gameState, ball) || isYoyoActive(gameState, ball)) { ctx.beginPath(); ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight); ctx.strokeStyle = gameState.puckColor; ctx.bezierCurveTo( gameState.puckPosition, gameState.gameZoneHeight, gameState.puckPosition, ball.y, ball.x, ball.y, ); ctx.stroke(); ctx.lineWidth = 2; ctx.setLineDash(emptyArray); } if (gameState.perks.clairvoyant && gameState.ballStickToPuck) { ctx.strokeStyle = gameState.ballsColor; ctx.beginPath(); ctx.moveTo(ball.x, ball.y); ctx.lineTo(ball.x + ball.vx * 10, ball.y + ball.vy * 10); ctx.stroke(); } }); // The puck ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; drawPuck( ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight, 0, gameState.perks.concave_puck, gameState.perks.streak_shots && hasCombo ? getDashOffset(gameState) : -1, ); if (gameState.combo > 1) { ctx.globalCompositeOperation = "source-over"; const comboText = "x " + gameState.combo; const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8; const totalWidth = comboTextWidth + gameState.coinSize * 2; const left = gameState.puckPosition - totalWidth / 2; if (totalWidth < gameState.puckWidth) { drawCoin( ctx, "gold", gameState.coinSize, left + gameState.coinSize / 2, gameState.gameZoneHeight - gameState.puckHeight / 2, gameState.puckColor, 0, ); drawText( ctx, comboText, "#000", gameState.puckHeight, left + gameState.coinSize * 1.5, gameState.gameZoneHeight - gameState.puckHeight / 2, true, ); } else { drawText( ctx, comboTextWidth > gameState.puckWidth ? gameState.combo.toString() : comboText, "#000", comboTextWidth > gameState.puckWidth ? 12 : 20, gameState.puckPosition, gameState.gameZoneHeight - gameState.puckHeight / 2, false, ); } } // Borders ctx.globalCompositeOperation = "source-over"; ctx.globalAlpha = gameState.perks.unbounded ? 0.1 : 1; if (gameState.offsetXRoundedDown) { // draw outside of gaming area to avoid capturing borders in recordings ctx.fillStyle = hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor; drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.left_is_lava && !gameState.perks.unbounded && "red") || "white", gameState.offsetX - 1, 0, gameState.offsetX - 1, height, gameState.perks.unbounded ? 0.1 : 1, ); drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.right_is_lava && !gameState.perks.unbounded && "red") || "white", width - gameState.offsetX + 1, 0, width - gameState.offsetX + 1, height, gameState.perks.unbounded ? 0.1 : 1, ); } else { ctx.fillStyle = "red"; drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.left_is_lava && !gameState.perks.unbounded && "red") || "", 0, 0, 0, height, 1, ); drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.right_is_lava && !gameState.perks.unbounded && "red") || "", width - 1, 0, width - 1, height, 1, ); } ctx.globalAlpha = gameState.perks.unbounded > 1 ? 0.1 : 1; drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.top_is_lava && "red") || "", gameState.offsetXRoundedDown, 1, width - gameState.offsetXRoundedDown, 1, 1, ); ctx.globalAlpha = 1; drawStraightLine( ctx, gameState, (hasCombo && gameState.perks.compound_interest && "red") || (isOptionOn("mobile-mode") && "white") || "", gameState.offsetXRoundedDown, gameState.gameZoneHeight, width - gameState.offsetXRoundedDown, gameState.gameZoneHeight, 1, ); if (isOptionOn("mobile-mode") && !gameState.running) { drawText( ctx, t("play.mobile_press_to_play"), gameState.puckColor, gameState.puckHeight, gameState.canvasWidth / 2, gameState.gameZoneHeight + (gameState.canvasHeight - gameState.gameZoneHeight) / 2, ); } if (shaked) { ctx.resetTransform(); } } function drawStraightLine( ctx: CanvasRenderingContext2D, gameState: GameState, mode: "white" | "" | "red", x1, y1, x2, y2, alpha = 1, ) { ctx.globalAlpha = alpha; if (!mode) return; if (mode == "red") { ctx.strokeStyle = "red"; ctx.lineDashOffset = getDashOffset(gameState); ctx.lineWidth = 2; ctx.setLineDash(redBorderDash); } else { ctx.strokeStyle = "white"; ctx.lineWidth = 1; } ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); if (mode == "red") { ctx.setLineDash(emptyArray); ctx.lineWidth = 1; } ctx.globalAlpha = 1; } let cachedBricksRender = document.createElement("canvas"); let cachedBricksRenderKey = ""; export function renderAllBricks() { ctx.globalAlpha = 1; const hasCombo = gameState.combo > baseCombo(gameState); const redBorderOnBricksWithWrongColor = hasCombo && gameState.perks.picky_eater && !isOptionOn("basic"); const redColorOnAllBricks = !!( gameState.lastPuckMove && gameState.perks.passive_income && hasCombo && gameState.lastPuckMove > gameState.levelTime - 250 * gameState.perks.passive_income ); let offset = getDashOffset(gameState); if ( !( redBorderOnBricksWithWrongColor || redColorOnAllBricks || gameState.perks.reach || gameState.perks.zen ) ) { offset = 0; } const clairVoyance = gameState.perks.clairvoyant && gameState.brickHP.reduce((a, b) => a + b, 0); const newKey = gameState.gameZoneWidth + "_" + gameState.bricks.join("_") + bombSVG.complete + "_" + redBorderOnBricksWithWrongColor + "_" + redColorOnAllBricks + "_" + gameState.ballsColor + "_" + gameState.perks.pierce_color + "_" + clairVoyance + "_" + offset; if (newKey !== cachedBricksRenderKey) { cachedBricksRenderKey = newKey; cachedBricksRender.width = gameState.gameZoneWidth; cachedBricksRender.height = gameState.gameZoneWidth + 1; const canctx = cachedBricksRender.getContext( "2d", ) as CanvasRenderingContext2D; canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth); canctx.resetTransform(); canctx.translate(-gameState.offsetX, 0); // Bricks gameState.bricks.forEach((color, index) => { const x = brickCenterX(gameState, index), y = brickCenterY(gameState, index); if (!color) return; let redBecauseOfReach = gameState.perks.reach && countBricksAbove(gameState, index) && !countBricksBelow(gameState, index); let redBorder = (gameState.ballsColor !== color && color !== "black" && redBorderOnBricksWithWrongColor) || (hasCombo && gameState.perks.zen && color === "black") || redBecauseOfReach || redColorOnAllBricks; canctx.globalCompositeOperation = "source-over"; drawBrick( canctx, color, x, y, redBorder ? offset : -1, gameState.perks.clairvoyant >= 2, ); if (gameState.brickHP[index] > 1 && gameState.perks.clairvoyant) { canctx.globalCompositeOperation = gameState.perks.clairvoyant >= 2 ? "source-over" : "destination-out"; drawText( canctx, gameState.brickHP[index].toString(), color, gameState.puckHeight, x, y, ); } if (color === "black") { canctx.globalCompositeOperation = "source-over"; drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); } }); } ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); } let cachedGraphics: { [k: string]: HTMLCanvasElement } = {}; export function drawPuck( ctx: CanvasRenderingContext2D, color: colorString, puckWidth: number, puckHeight: number, yOffset = 0, concave_puck: number, redBorderOffset: number, ) { const key = "puck" + color + "_" + puckWidth + "_" + puckHeight + "_" + concave_puck + "_" + redBorderOffset; if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = puckWidth; can.height = puckHeight * 2; const canctx = can.getContext("2d") as CanvasRenderingContext2D; canctx.fillStyle = color; canctx.beginPath(); canctx.moveTo(0, puckHeight * 2); if (concave_puck) { canctx.lineTo(0, puckHeight * 0.75); canctx.bezierCurveTo( puckWidth / 2, (puckHeight * (2 + concave_puck)) / 3, puckWidth / 2, (puckHeight * (2 + concave_puck)) / 3, puckWidth, puckHeight * 0.75, ); canctx.lineTo(puckWidth, puckHeight * 2); } else { canctx.lineTo(0, puckHeight * 1.25); canctx.bezierCurveTo( 0, puckHeight * 0.75, puckWidth, puckHeight * 0.75, puckWidth, puckHeight * 1.25, ); canctx.lineTo(puckWidth, puckHeight * 2); } canctx.fill(); if (redBorderOffset !== -1) { canctx.strokeStyle = "red"; canctx.lineWidth = 4; canctx.setLineDash(redBorderDash); canctx.lineDashOffset = redBorderOffset; canctx.stroke(); } cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], Math.round(gameState.puckPosition - puckWidth / 2), gameState.gameZoneHeight - puckHeight * 2 + yOffset, ); } export function drawBall( ctx: CanvasRenderingContext2D, color: colorString, width: number, x: number, y: number, borderColor = "", ) { const key = "ball" + color + "_" + width + "_" + borderColor; const size = Math.round(width); if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = size; can.height = size; const canctx = can.getContext("2d") as CanvasRenderingContext2D; canctx.beginPath(); canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); canctx.fillStyle = color; canctx.fill(); if (borderColor) { canctx.lineWidth = 2; canctx.strokeStyle = borderColor; canctx.stroke(); } cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2), ); } const angles = 32; export function drawCoin( ctx: CanvasRenderingContext2D, color: colorString, size: number, x: number, y: number, borderColor: colorString, rawAngle: number, ) { const angle = ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % angles; const key = "coin with halo" + "_" + color + "_" + size + "_" + borderColor + "_" + (color === "gold" ? angle : "whatever"); if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = size; can.height = size; const canctx = can.getContext("2d") as CanvasRenderingContext2D; // coin canctx.beginPath(); canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); canctx.fillStyle = color; canctx.fill(); canctx.strokeStyle = borderColor; if (borderColor == "red") { canctx.lineWidth = 2; canctx.setLineDash(redBorderDash); } canctx.stroke(); if (color === "gold") { // Fill in canctx.beginPath(); canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI); canctx.fillStyle = "rgba(255,255,255,0.5)"; canctx.fill(); canctx.translate(size / 2, size / 2); canctx.rotate(angle / 16); canctx.translate(-size / 2, -size / 2); canctx.globalCompositeOperation = "multiply"; drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); } cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2), ); } export function drawFuzzyBall( ctx: CanvasRenderingContext2D, color: colorString, width: number, x: number, y: number, ) { const key = "fuzzy-circle" + color + "_" + width; if (!color) debugger; const size = Math.round(width * 3); if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = size; can.height = size; const canctx = can.getContext("2d") as CanvasRenderingContext2D; const gradient = canctx.createRadialGradient( size / 2, size / 2, 0, size / 2, size / 2, size / 2, ); gradient.addColorStop(0, color); gradient.addColorStop(1, "transparent"); canctx.fillStyle = gradient; canctx.fillRect(0, 0, size, size); cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2), ); } export function drawBrick( ctx: CanvasRenderingContext2D, color: colorString, x: number, y: number, offset: number = 0, borderOnly: boolean, ) { const tlx = Math.ceil(x - gameState.brickWidth / 2); const tly = Math.ceil(y - gameState.brickWidth / 2); const brx = Math.ceil(x + gameState.brickWidth / 2) - 1; const bry = Math.ceil(y + gameState.brickWidth / 2) - 1; const width = brx - tlx, height = bry - tly; const key = "brick" + color + "_" + "_" + width + "_" + height + "_" + offset + "_" + borderOnly; if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = width; can.height = height; const bord = 4; const cornerRadius = 2; const canctx = can.getContext("2d") as CanvasRenderingContext2D; canctx.fillStyle = color; canctx.setLineDash(offset !== -1 ? redBorderDash : emptyArray); canctx.lineDashOffset = offset; canctx.strokeStyle = offset !== -1 ? "red" : color; canctx.lineJoin = "round"; canctx.lineWidth = bord; roundRect( canctx, bord / 2, bord / 2, width - bord, height - bord, cornerRadius, ); if (!borderOnly) { canctx.fill(); } canctx.stroke(); cachedGraphics[key] = can; } ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); // It's not easy to have a 1px gap between bricks without antialiasing } export function roundRect( ctx: CanvasRenderingContext2D, x: number, y: number, width: number, height: number, radius: number, ) { ctx.beginPath(); ctx.moveTo(x + radius, y); ctx.lineTo(x + width - radius, y); ctx.quadraticCurveTo(x + width, y, x + width, y + radius); ctx.lineTo(x + width, y + height - radius); ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); ctx.lineTo(x + radius, y + height); ctx.quadraticCurveTo(x, y + height, x, y + height - radius); ctx.lineTo(x, y + radius); ctx.quadraticCurveTo(x, y, x + radius, y); ctx.closePath(); } export function drawIMG( ctx: CanvasRenderingContext2D, img: HTMLImageElement, size: number, x: number, y: number, ) { const key = "svg" + img + "_" + size + "_" + img.complete; if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = size; can.height = size; const canctx = can.getContext("2d") as CanvasRenderingContext2D; const ratio = size / Math.max(img.width, img.height); const w = img.width * ratio; const h = img.height * ratio; canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2), ); } export function drawText( ctx: CanvasRenderingContext2D, text: string, color: colorString, fontSize: number, x: number, y: number, left = false, ) { const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; if (!cachedGraphics[key]) { const can = document.createElement("canvas"); can.width = fontSize * text.length; can.height = fontSize; const canctx = can.getContext("2d") as CanvasRenderingContext2D; canctx.fillStyle = color; canctx.textAlign = left ? "left" : "center"; canctx.textBaseline = "middle"; canctx.font = fontSize + "px monospace"; canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); cachedGraphics[key] = can; } ctx.drawImage( cachedGraphics[key], left ? x : Math.round(x - cachedGraphics[key].width / 2), Math.round(y - cachedGraphics[key].height / 2), ); } export const scoreDisplay = document.getElementById( "score", ) as HTMLButtonElement; const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement; const emptyArray = []; const redBorderDash = [5, 5]; export function getDashOffset(gameState: GameState) { if (isOptionOn("basic")) { return 0; } return Math.floor(((gameState.levelTime % 500) / 500) * 10) % 10; }