2025-03-18 14:16:12 +01:00
|
|
|
import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
|
|
|
brickCenterX,
|
|
|
|
brickCenterY,
|
|
|
|
currentLevelInfo,
|
|
|
|
isTelekinesisActive,
|
|
|
|
max_levels,
|
|
|
|
} from "./game_utils";
|
|
|
|
import { colorString, GameState } from "./types";
|
|
|
|
import { t } from "./i18n/i18n";
|
|
|
|
import { gameState } from "./game";
|
|
|
|
import { isOptionOn } from "./options";
|
2025-03-16 14:29:14 +01:00
|
|
|
|
|
|
|
export const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
|
|
|
|
export const ctx = gameCanvas.getContext("2d", {
|
2025-03-16 17:45:29 +01:00
|
|
|
alpha: false,
|
2025-03-16 14:29:14 +01:00
|
|
|
}) as CanvasRenderingContext2D;
|
|
|
|
export const bombSVG = document.createElement("img");
|
2025-03-17 11:50:13 +01:00
|
|
|
|
|
|
|
bombSVG.src =
|
|
|
|
"data:image/svg+xml;base64," +
|
|
|
|
btoa(`<svg width="144" height="144" viewBox="0 0 38.101 38.099" xmlns="http://www.w3.org/2000/svg">
|
|
|
|
<path d="m6.1528 26.516c-2.6992-3.4942-2.9332-8.281-.58305-11.981a10.454 10.454 0 017.3701-4.7582c1.962-.27726 4.1646.05953 5.8835.90027l.45013.22017.89782-.87417c.83748-.81464.91169-.87499 1.0992-.90271.40528-.058713.58876.03425 1.1971.6116l.55451.52679 1.0821-1.0821c1.1963-1.1963 1.383-1.3357 2.1039-1.5877.57898-.20223 1.5681-.19816 2.1691.00897 1.4613.50314 2.3673 1.7622 2.3567 3.2773-.0058.95654-.24464 1.5795-.90924 2.3746-.40936.48928-.55533.81057-.57898 1.2737-.02039.41018.1109.77714.42322 1.1792.30172.38816.3694.61323.2797.93044-.12803.45666-.56674.71598-1.0242.60507-.601-.14597-1.3031-1.3088-1.3969-2.3126-.09459-1.0161.19245-1.8682.92392-2.7432.42567-.50885.5643-.82851.5643-1.3031 0-.50151-.14026-.83177-.51211-1.2028-.50966-.50966-1.0968-.64829-1.781-.41996l-.37348.12477-2.1006 2.1006.52597.55696c.45421.48194.5325.58876.57898.78855.09622.41588.07502.45014-.88396 1.4548l-.87173.9125.26339.57979a10.193 10.193 0 01.9231 4.1001c.03996 2.046-.41996 3.8082-1.4442 5.537-.55044.928-1.0185 1.5013-1.8968 2.3241-.83503.78284-1.5526 1.2827-2.4904 1.7361-3.4266 1.657-7.4721 1.3422-10.549-.82035-.73473-.51782-1.7312-1.4621-2.2515-2.1357zm21.869-4.5584c-.0579-.19734-.05871-2.2662 0-2.4545.11906-.39142.57898-.63361 1.0038-.53005.23812.05708.54147.32455.6116.5382.06279.19163.06769 2.1805.0065 2.3811-.12558.40773-.61649.67602-1.0462.57164-.234-.05708-.51615-.30498-.57568-.50722m3.0417-2.6013c-.12313-.6222.37837-1.1049 1.0479-1.0079.18348.0261.25279.08399 1.0071.83911.75838.75838.81301.82362.84074 1.0112.10193.68499-.40365 1.1938-1.034 1.0405-.1949-.0473-.28786-.12558-1.0144-.85216-.7649-.76409-.80241-.81057-.84645-1.0316m.61323-3.0629a.85623.85623 0 01.59284-.99975c.28949-.09214 2.1814-.08318 2.3917.01141.38734.17369.6279.61078.53984.98181-.06035.25606-.35391.57327-.60181.64992-.25279.07747-2.2278.053-2.4097-.03017-.26013-.11906-.46318-.36125-.51374-.61323" fill="#fff" opacity="0.3"/>
|
|
|
|
</svg>`);
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export const background = document.createElement("img");
|
|
|
|
export const backgroundCanvas = document.createElement("canvas");
|
|
|
|
|
|
|
|
export function render(gameState: GameState) {
|
2025-03-16 17:45:29 +01:00
|
|
|
const level = currentLevelInfo(gameState);
|
|
|
|
const { width, height } = gameCanvas;
|
|
|
|
if (!width || !height) return;
|
|
|
|
|
|
|
|
if (gameState.currentLevel || gameState.levelTime) {
|
|
|
|
menuLabel.innerText = t("play.current_lvl", {
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
|
|
|
});
|
|
|
|
} else {
|
|
|
|
menuLabel.innerText = t("play.menu_label");
|
|
|
|
}
|
|
|
|
scoreDisplay.innerText = `$${gameState.score}`;
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
scoreDisplay.className =
|
|
|
|
gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : "";
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
// Clear
|
|
|
|
if (!isOptionOn("basic") && !level.color && level.svg) {
|
|
|
|
// Without this the light trails everything
|
2025-03-16 14:29:14 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
ctx.globalAlpha = 1;
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.fillStyle = "#000";
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.globalCompositeOperation = "screen";
|
|
|
|
ctx.globalAlpha = 0.6;
|
2025-03-18 14:16:12 +01:00
|
|
|
forEachLiveOne(gameState.coins, (coin) => {
|
|
|
|
drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
gameState.balls.forEach((ball) => {
|
|
|
|
drawFuzzyBall(
|
|
|
|
ctx,
|
|
|
|
gameState.ballsColor,
|
|
|
|
gameState.ballSize * 2,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
});
|
2025-03-16 17:45:29 +01:00
|
|
|
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;
|
2025-03-18 14:16:12 +01:00
|
|
|
forEachLiveOne(gameState.lights, (flash) => {
|
|
|
|
const { x, y, time, color, size, duration } = flash;
|
2025-03-16 17:45:29 +01:00
|
|
|
const elapsed = gameState.levelTime - time;
|
|
|
|
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
|
2025-03-18 14:16:12 +01:00
|
|
|
drawFuzzyBall(ctx, color, size, x, y);
|
|
|
|
});
|
|
|
|
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);
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
2025-03-18 14:16:12 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
// 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);
|
|
|
|
const pattern = ctx.createPattern(background, "repeat");
|
|
|
|
if (pattern) {
|
|
|
|
bgctx.fillStyle = pattern;
|
|
|
|
bgctx.fillRect(0, 0, width, height);
|
|
|
|
}
|
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.drawImage(backgroundCanvas, 0, 0);
|
|
|
|
} else {
|
|
|
|
// Background not loaded yes
|
|
|
|
ctx.fillStyle = "#000";
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else {
|
|
|
|
ctx.globalAlpha = 1;
|
2025-03-16 14:29:14 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.fillStyle = level.color || "#000";
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
2025-03-18 14:16:12 +01:00
|
|
|
forEachLiveOne(gameState.particles, (flash) => {
|
|
|
|
const { x, y, time, color, size, duration } = flash;
|
2025-03-16 17:45:29 +01:00
|
|
|
const elapsed = gameState.levelTime - time;
|
|
|
|
ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
|
2025-03-18 14:16:12 +01:00
|
|
|
drawBall(ctx, color, size, x, y);
|
2025-03-16 14:29:14 +01:00
|
|
|
});
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
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")) {
|
|
|
|
if (shaked) {
|
|
|
|
gameCanvas.style.filter =
|
|
|
|
"brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")";
|
|
|
|
} else {
|
|
|
|
gameCanvas.style.filter = "";
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
// Coins
|
|
|
|
ctx.globalAlpha = 1;
|
2025-03-18 14:16:12 +01:00
|
|
|
forEachLiveOne(gameState.coins, (coin) => {
|
|
|
|
ctx.globalCompositeOperation =
|
|
|
|
coin.color === "gold" || level.color ? "source-over" : "screen";
|
|
|
|
drawCoin(
|
|
|
|
ctx,
|
|
|
|
coin.color,
|
|
|
|
coin.size,
|
|
|
|
coin.x,
|
|
|
|
coin.y,
|
|
|
|
level.color || "black",
|
|
|
|
coin.a,
|
|
|
|
);
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
// Black shadow around balls
|
|
|
|
if (!isOptionOn("basic")) {
|
2025-03-16 14:29:14 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-03-18 14:16:12 +01:00
|
|
|
ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20);
|
2025-03-16 14:29:14 +01:00
|
|
|
gameState.balls.forEach((ball) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
drawBall(
|
|
|
|
ctx,
|
|
|
|
level.color || "#000",
|
|
|
|
gameState.ballSize * 6,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
});
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
renderAllBricks();
|
|
|
|
|
|
|
|
ctx.globalCompositeOperation = "screen";
|
2025-03-18 14:16:12 +01:00
|
|
|
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);
|
|
|
|
});
|
2025-03-16 17:45:29 +01:00
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
forEachLiveOne(gameState.particles, (particle) => {
|
|
|
|
const { x, y, time, color, size, duration } = particle;
|
2025-03-16 17:45:29 +01:00
|
|
|
const elapsed = gameState.levelTime - time;
|
|
|
|
ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
|
2025-03-18 14:16:12 +01:00
|
|
|
ctx.globalCompositeOperation = "screen";
|
|
|
|
drawBall(ctx, color, size, x, y);
|
|
|
|
drawFuzzyBall(ctx, color, size, x, y);
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
if (gameState.perks.extra_life) {
|
2025-03-16 14:29:14 +01:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.fillStyle = gameState.puckColor;
|
|
|
|
for (let i = 0; i < gameState.perks.extra_life; i++) {
|
|
|
|
ctx.fillRect(
|
|
|
|
gameState.offsetXRoundedDown,
|
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i,
|
|
|
|
gameState.gameZoneWidthRoundedUp,
|
|
|
|
1,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
gameState.balls.forEach((ball) => {
|
|
|
|
// The white border around is to distinguish colored balls from coins/bg
|
|
|
|
drawBall(
|
|
|
|
ctx,
|
|
|
|
gameState.ballsColor,
|
|
|
|
gameState.ballSize,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
gameState.puckColor,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (isTelekinesisActive(gameState, ball)) {
|
|
|
|
ctx.strokeStyle = gameState.puckColor;
|
|
|
|
ctx.beginPath();
|
|
|
|
ctx.bezierCurveTo(
|
|
|
|
gameState.puckPosition,
|
|
|
|
gameState.gameZoneHeight,
|
|
|
|
gameState.puckPosition,
|
|
|
|
ball.y,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
);
|
|
|
|
ctx.stroke();
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
// The puck
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) {
|
|
|
|
drawPuck(
|
|
|
|
ctx,
|
|
|
|
"red",
|
|
|
|
gameState.puckWidth,
|
|
|
|
gameState.puckHeight,
|
|
|
|
-2,
|
|
|
|
!!gameState.perks.concave_puck,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
drawPuck(
|
|
|
|
ctx,
|
|
|
|
gameState.puckColor,
|
|
|
|
gameState.puckWidth,
|
|
|
|
gameState.puckHeight,
|
|
|
|
0,
|
|
|
|
!!gameState.perks.concave_puck,
|
|
|
|
);
|
|
|
|
|
|
|
|
if (gameState.combo > 1) {
|
2025-03-16 14:29:14 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-03-16 17:45:29 +01:00
|
|
|
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,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
} else {
|
2025-03-16 17:45:29 +01:00
|
|
|
drawText(
|
|
|
|
ctx,
|
|
|
|
comboText,
|
|
|
|
"#FFF",
|
|
|
|
gameState.puckHeight,
|
|
|
|
gameState.puckPosition,
|
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
|
|
|
false,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
// Borders
|
|
|
|
const hasCombo = gameState.combo > baseCombo(gameState);
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
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;
|
|
|
|
ctx.fillRect(gameState.offsetX - 1, 0, 1, height);
|
|
|
|
ctx.fillStyle =
|
|
|
|
hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor;
|
|
|
|
ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height);
|
|
|
|
} else {
|
|
|
|
ctx.fillStyle = "red";
|
|
|
|
if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height);
|
|
|
|
if (hasCombo && gameState.perks.right_is_lava)
|
|
|
|
ctx.fillRect(width - 1, 0, 1, height);
|
|
|
|
}
|
|
|
|
|
|
|
|
if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) {
|
|
|
|
ctx.fillStyle = "red";
|
|
|
|
ctx.fillRect(
|
|
|
|
gameState.offsetXRoundedDown,
|
|
|
|
0,
|
|
|
|
gameState.gameZoneWidthRoundedUp,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
const redBottom =
|
|
|
|
gameState.perks.compound_interest && gameState.combo > baseCombo(gameState);
|
|
|
|
ctx.fillStyle = redBottom ? "red" : gameState.puckColor;
|
|
|
|
if (isOptionOn("mobile-mode")) {
|
|
|
|
ctx.fillRect(
|
|
|
|
gameState.offsetXRoundedDown,
|
|
|
|
gameState.gameZoneHeight,
|
|
|
|
gameState.gameZoneWidthRoundedUp,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
if (!gameState.running) {
|
|
|
|
drawText(
|
|
|
|
ctx,
|
|
|
|
t("play.mobile_press_to_play"),
|
|
|
|
gameState.puckColor,
|
|
|
|
gameState.puckHeight,
|
|
|
|
gameState.canvasWidth / 2,
|
|
|
|
gameState.gameZoneHeight +
|
|
|
|
(gameState.canvasHeight - gameState.gameZoneHeight) / 2,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else if (redBottom) {
|
|
|
|
ctx.fillRect(
|
|
|
|
gameState.offsetXRoundedDown,
|
|
|
|
gameState.gameZoneHeight - 1,
|
|
|
|
gameState.gameZoneWidthRoundedUp,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
if (shaked) {
|
|
|
|
ctx.resetTransform();
|
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let cachedBricksRender = document.createElement("canvas");
|
|
|
|
let cachedBricksRenderKey = "";
|
|
|
|
|
|
|
|
export function renderAllBricks() {
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
|
|
const redBorderOnBricksWithWrongColor =
|
|
|
|
gameState.combo > baseCombo(gameState) &&
|
|
|
|
gameState.perks.picky_eater &&
|
|
|
|
!isOptionOn("basic");
|
|
|
|
|
|
|
|
const newKey =
|
|
|
|
gameState.gameZoneWidth +
|
|
|
|
"_" +
|
|
|
|
gameState.bricks.join("_") +
|
|
|
|
bombSVG.complete +
|
|
|
|
"_" +
|
|
|
|
redBorderOnBricksWithWrongColor +
|
|
|
|
"_" +
|
|
|
|
gameState.ballsColor +
|
|
|
|
"_" +
|
|
|
|
gameState.perks.pierce_color;
|
|
|
|
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;
|
|
|
|
|
|
|
|
const borderColor =
|
|
|
|
(gameState.ballsColor !== color &&
|
|
|
|
color !== "black" &&
|
|
|
|
redBorderOnBricksWithWrongColor &&
|
|
|
|
"red") ||
|
|
|
|
color;
|
|
|
|
|
|
|
|
drawBrick(canctx, color, borderColor, x, y);
|
|
|
|
if (color === "black") {
|
|
|
|
canctx.globalCompositeOperation = "source-over";
|
|
|
|
drawIMG(canctx, bombSVG, gameState.brickWidth, x, y);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx.drawImage(cachedBricksRender, gameState.offsetX, 0);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let cachedGraphics: { [k: string]: HTMLCanvasElement } = {};
|
|
|
|
|
|
|
|
export function drawPuck(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
puckWidth: number,
|
|
|
|
puckHeight: number,
|
|
|
|
yOffset = 0,
|
|
|
|
flipped: boolean,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
const key =
|
|
|
|
"puck" + color + "_" + puckWidth + "_" + puckHeight + "_" + flipped;
|
|
|
|
|
|
|
|
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 (flipped) {
|
|
|
|
canctx.lineTo(0, puckHeight * 0.75);
|
|
|
|
canctx.bezierCurveTo(
|
|
|
|
puckWidth / 2,
|
|
|
|
puckHeight,
|
|
|
|
puckWidth / 2,
|
|
|
|
puckHeight * 1,
|
|
|
|
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);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
canctx.fill();
|
|
|
|
cachedGraphics[key] = can;
|
|
|
|
}
|
|
|
|
|
|
|
|
ctx.drawImage(
|
|
|
|
cachedGraphics[key],
|
|
|
|
Math.round(gameState.puckPosition - puckWidth / 2),
|
|
|
|
gameState.gameZoneHeight - puckHeight * 2 + yOffset,
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawBall(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
width: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
borderColor = "",
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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();
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
|
|
|
|
cachedGraphics[key] = can;
|
|
|
|
}
|
|
|
|
ctx.drawImage(
|
|
|
|
cachedGraphics[key],
|
|
|
|
Math.round(x - size / 2),
|
|
|
|
Math.round(y - size / 2),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const angles = 32;
|
|
|
|
|
|
|
|
export function drawCoin(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
size: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
borderColor: colorString,
|
|
|
|
rawAngle: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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();
|
|
|
|
|
|
|
|
if (color === "gold") {
|
|
|
|
canctx.strokeStyle = borderColor;
|
|
|
|
canctx.stroke();
|
|
|
|
|
|
|
|
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);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
cachedGraphics[key] = can;
|
|
|
|
}
|
|
|
|
ctx.drawImage(
|
|
|
|
cachedGraphics[key],
|
|
|
|
Math.round(x - size / 2),
|
|
|
|
Math.round(y - size / 2),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawFuzzyBall(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
width: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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,
|
2025-03-16 14:29:14 +01:00
|
|
|
);
|
2025-03-16 17:45:29 +01:00
|
|
|
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),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawBrick(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
borderColor: colorString,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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 + "_" + borderColor + "_" + width + "_" + height;
|
|
|
|
|
|
|
|
if (!cachedGraphics[key]) {
|
|
|
|
const can = document.createElement("canvas");
|
|
|
|
can.width = width;
|
|
|
|
can.height = height;
|
|
|
|
const bord = 2;
|
|
|
|
const cornerRadius = 2;
|
|
|
|
const canctx = can.getContext("2d") as CanvasRenderingContext2D;
|
|
|
|
|
|
|
|
canctx.fillStyle = color;
|
|
|
|
canctx.strokeStyle = borderColor;
|
|
|
|
canctx.lineJoin = "round";
|
|
|
|
canctx.lineWidth = bord;
|
|
|
|
roundRect(
|
|
|
|
canctx,
|
|
|
|
bord / 2,
|
|
|
|
bord / 2,
|
|
|
|
width - bord,
|
|
|
|
height - bord,
|
|
|
|
cornerRadius,
|
|
|
|
);
|
|
|
|
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
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function roundRect(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
width: number,
|
|
|
|
height: number,
|
|
|
|
radius: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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();
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawIMG(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
img: HTMLImageElement,
|
|
|
|
size: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawText(
|
2025-03-16 17:45:29 +01:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
text: string,
|
|
|
|
color: colorString,
|
|
|
|
fontSize: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
left = false,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
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),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
export const scoreDisplay = document.getElementById(
|
|
|
|
"score",
|
|
|
|
) as HTMLButtonElement;
|
|
|
|
const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement;
|