2025-04-16 15:30:42 +02:00
|
|
|
import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
2025-04-10 14:49:28 +02:00
|
|
|
ballTransparency,
|
2025-04-06 15:38:30 +02:00
|
|
|
brickCenterX,
|
|
|
|
brickCenterY,
|
|
|
|
currentLevelInfo,
|
2025-04-16 15:30:20 +02:00
|
|
|
getCoinRenderColor,
|
2025-04-18 21:17:32 +02:00
|
|
|
getCornerOffset,
|
2025-04-06 15:38:30 +02:00
|
|
|
isMovingWhilePassiveIncome,
|
|
|
|
isPickyEatingPossible,
|
|
|
|
max_levels,
|
|
|
|
reachRedRowIndex,
|
|
|
|
telekinesisEffectRate,
|
|
|
|
yoyoEffectRate,
|
2025-03-16 17:45:29 +01:00
|
|
|
} from "./game_utils";
|
2025-04-16 15:30:42 +02:00
|
|
|
import { colorString, GameState } from "./types";
|
|
|
|
import { t } from "./i18n/i18n";
|
|
|
|
import { gameState, lastMeasuredFPS, startWork } from "./game";
|
|
|
|
import { isOptionOn } from "./options";
|
2025-04-08 21:54:19 +02:00
|
|
|
import {
|
|
|
|
catchRateBest,
|
|
|
|
catchRateGood,
|
|
|
|
levelTimeBest,
|
2025-04-09 11:28:32 +02:00
|
|
|
levelTimeGood,
|
|
|
|
missesBest,
|
|
|
|
missesGood,
|
2025-04-08 21:54:19 +02:00
|
|
|
wallBouncedBest,
|
2025-04-09 11:28:32 +02:00
|
|
|
wallBouncedGood,
|
2025-04-08 21:54:19 +02:00
|
|
|
} from "./pure_functions";
|
2025-04-16 15:30:42 +02:00
|
|
|
import { getCurrentMaxCoins } from "./settings";
|
2025-03-16 14:29:14 +01:00
|
|
|
|
|
|
|
export const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
|
|
|
|
export const ctx = gameCanvas.getContext("2d", {
|
2025-04-06 15:38:30 +02:00
|
|
|
alpha: false,
|
2025-03-16 14:29:14 +01:00
|
|
|
}) as CanvasRenderingContext2D;
|
2025-04-03 15:15:00 +02:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export const bombSVG = document.createElement("img");
|
2025-03-17 11:50:13 +01:00
|
|
|
bombSVG.src =
|
2025-04-06 15:38:30 +02:00
|
|
|
"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">
|
2025-03-17 11:50:13 +01:00
|
|
|
<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-29 21:28:05 +01:00
|
|
|
bombSVG.onload = () => (gameState.needsRender = true);
|
2025-03-17 11:50:13 +01:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export const background = document.createElement("img");
|
2025-03-30 21:07:58 +02:00
|
|
|
background.onload = () => (gameState.needsRender = true);
|
2025-03-16 14:29:14 +01:00
|
|
|
export const backgroundCanvas = document.createElement("canvas");
|
|
|
|
|
2025-04-03 15:15:00 +02:00
|
|
|
export const haloCanvas = document.createElement("canvas");
|
|
|
|
const haloCanvasCtx = haloCanvas.getContext("2d", {
|
2025-04-06 15:38:30 +02:00
|
|
|
alpha: false,
|
2025-04-03 15:15:00 +02:00
|
|
|
}) as CanvasRenderingContext2D;
|
|
|
|
|
2025-04-18 21:17:32 +02:00
|
|
|
export function getHaloScale() {
|
|
|
|
return 16 * (isOptionOn("precise_lighting") ? 1 : 2);
|
|
|
|
}
|
2025-04-03 15:15:00 +02:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function render(gameState: GameState) {
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:init");
|
2025-04-06 15:38:30 +02:00
|
|
|
const level = currentLevelInfo(gameState);
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
const hasCombo = gameState.combo > baseCombo(gameState);
|
|
|
|
const { width, height } = gameCanvas;
|
|
|
|
if (!width || !height) return;
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
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");
|
|
|
|
}
|
|
|
|
|
|
|
|
const catchRate = gameState.levelSpawnedCoins
|
|
|
|
? (gameState.levelSpawnedCoins - gameState.levelLostCoins) /
|
|
|
|
gameState.levelSpawnedCoins
|
|
|
|
: 1;
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:scoreDisplay");
|
2025-04-06 15:38:30 +02:00
|
|
|
scoreDisplay.innerHTML =
|
2025-04-18 17:15:47 +02:00
|
|
|
(isOptionOn("show_fps") || gameState.startParams.computer_controlled
|
2025-04-06 15:38:30 +02:00
|
|
|
? `
|
2025-04-15 21:25:27 +02:00
|
|
|
<span class="${(Math.abs(lastMeasuredFPS - 60) < 2 && " ") || (Math.abs(lastMeasuredFPS - 60) < 10 && "good") || "bad"}">
|
2025-03-29 21:05:53 +01:00
|
|
|
${lastMeasuredFPS} FPS
|
|
|
|
</span><span> / </span>
|
2025-03-29 21:28:05 +01:00
|
|
|
`
|
2025-04-06 15:38:30 +02:00
|
|
|
: "") +
|
|
|
|
(isOptionOn("show_stats")
|
|
|
|
? `
|
2025-04-09 11:28:32 +02:00
|
|
|
<span class="${(catchRate > catchRateBest / 100 && "great") || (catchRate > catchRateGood / 100 && "good") || ""}" data-tooltip="${t("play.stats.coins_catch_rate")}">
|
2025-03-29 21:28:05 +01:00
|
|
|
${Math.floor(catchRate * 100)}%
|
2025-03-29 20:45:54 +01:00
|
|
|
</span><span> / </span>
|
2025-04-09 11:28:32 +02:00
|
|
|
<span class="${(gameState.levelTime < levelTimeBest * 1000 && "great") || (gameState.levelTime < levelTimeGood * 1000 && "good") || ""}" data-tooltip="${t("play.stats.levelTime")}">
|
2025-03-29 21:28:05 +01:00
|
|
|
${Math.ceil(gameState.levelTime / 1000)}s
|
2025-03-29 20:45:54 +01:00
|
|
|
</span><span> / </span>
|
2025-04-08 21:54:19 +02:00
|
|
|
<span class="${(gameState.levelWallBounces < wallBouncedBest && "great") || (gameState.levelWallBounces < wallBouncedGood && "good") || ""}" data-tooltip="${t("play.stats.levelWallBounces")}">
|
2025-03-30 21:07:58 +02:00
|
|
|
${gameState.levelWallBounces} B
|
|
|
|
</span><span> / </span>
|
2025-04-08 21:54:19 +02:00
|
|
|
<span class="${(gameState.levelMisses < missesBest && "great") || (gameState.levelMisses < missesGood && "good") || ""}" data-tooltip="${t("play.stats.levelMisses")}">
|
2025-03-29 20:45:54 +01:00
|
|
|
${gameState.levelMisses} M
|
|
|
|
</span><span> / </span>
|
2025-03-29 21:28:05 +01:00
|
|
|
`
|
2025-04-06 15:38:30 +02:00
|
|
|
: "") +
|
|
|
|
`<span class="score" data-tooltip="${t("play.score_tooltip")}">$${gameState.score}</span>`;
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
scoreDisplay.className =
|
2025-04-18 17:15:47 +02:00
|
|
|
(gameState.startParams.computer_controlled && "computer_controlled") ||
|
2025-04-12 20:58:24 +02:00
|
|
|
(gameState.lastScoreIncrease > gameState.levelTime - 500 && "active") ||
|
|
|
|
"";
|
2025-04-06 15:38:30 +02:00
|
|
|
// Clear
|
|
|
|
if (!isOptionOn("basic") && level.svg && level.color === "#000000") {
|
2025-04-18 21:17:32 +02:00
|
|
|
const haloScale = getHaloScale();
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:clear");
|
2025-04-18 22:06:16 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
haloCanvasCtx.globalCompositeOperation = "source-over";
|
|
|
|
haloCanvasCtx.globalAlpha = 0.99;
|
|
|
|
haloCanvasCtx.fillStyle = level.color;
|
|
|
|
haloCanvasCtx.fillRect(0, 0, width / haloScale, height / haloScale);
|
2025-04-03 15:15:00 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
const brightness = isOptionOn("extra_bright") ? 3 : 1;
|
|
|
|
haloCanvasCtx.globalCompositeOperation = "lighten";
|
2025-04-08 08:57:41 +02:00
|
|
|
haloCanvasCtx.globalAlpha =
|
|
|
|
0.1 + (0.5 * 10) / (liveCount(gameState.coins) + 10);
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:coins");
|
2025-04-06 10:13:10 +02:00
|
|
|
forEachLiveOne(gameState.coins, (coin) => {
|
2025-04-06 15:38:30 +02:00
|
|
|
const color = getCoinRenderColor(gameState, coin);
|
|
|
|
drawFuzzyBall(
|
|
|
|
haloCanvasCtx,
|
|
|
|
color,
|
|
|
|
(gameState.coinSize * 2 * brightness) / haloScale,
|
|
|
|
coin.x / haloScale,
|
|
|
|
coin.y / haloScale,
|
|
|
|
);
|
2025-03-29 21:28:05 +01:00
|
|
|
});
|
2025-04-10 21:40:45 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:balls");
|
2025-04-06 15:38:30 +02:00
|
|
|
gameState.balls.forEach((ball) => {
|
2025-04-10 21:40:45 +02:00
|
|
|
haloCanvasCtx.globalAlpha = 0.3 * (1 - ballTransparency(ball, gameState));
|
2025-04-06 15:38:30 +02:00
|
|
|
drawFuzzyBall(
|
|
|
|
haloCanvasCtx,
|
|
|
|
gameState.ballsColor,
|
2025-04-07 15:25:58 +02:00
|
|
|
(gameState.ballSize * 2 * brightness) / haloScale,
|
2025-04-06 15:38:30 +02:00
|
|
|
ball.x / haloScale,
|
|
|
|
ball.y / haloScale,
|
|
|
|
);
|
2025-04-06 10:13:10 +02:00
|
|
|
});
|
2025-04-15 21:25:27 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:bricks");
|
2025-04-06 15:38:30 +02:00
|
|
|
haloCanvasCtx.globalAlpha = 0.05;
|
|
|
|
gameState.bricks.forEach((color, index) => {
|
|
|
|
if (!color) return;
|
|
|
|
const x = brickCenterX(gameState, index),
|
|
|
|
y = brickCenterY(gameState, index);
|
|
|
|
drawFuzzyBall(
|
|
|
|
haloCanvasCtx,
|
|
|
|
color == "black" ? "#666666" : color,
|
2025-04-07 16:52:42 +02:00
|
|
|
// Perf could really go down there because of the size of the halo
|
|
|
|
Math.min(200, gameState.brickWidth * 1.5 * brightness) / haloScale,
|
2025-04-06 15:38:30 +02:00
|
|
|
x / haloScale,
|
|
|
|
y / haloScale,
|
|
|
|
);
|
2025-04-06 10:13:10 +02:00
|
|
|
});
|
2025-04-03 16:10:51 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:particles");
|
2025-04-06 15:38:30 +02:00
|
|
|
haloCanvasCtx.globalCompositeOperation = "screen";
|
|
|
|
forEachLiveOne(gameState.particles, (flash) => {
|
|
|
|
const { x, y, time, color, size, duration } = flash;
|
|
|
|
const elapsed = gameState.levelTime - time;
|
2025-04-07 16:52:42 +02:00
|
|
|
haloCanvasCtx.globalAlpha =
|
|
|
|
0.1 * Math.min(1, 2 - (elapsed / duration) * 2);
|
2025-04-06 15:38:30 +02:00
|
|
|
drawFuzzyBall(
|
|
|
|
haloCanvasCtx,
|
|
|
|
color,
|
|
|
|
(size * 3 * brightness) / haloScale,
|
|
|
|
x / haloScale,
|
|
|
|
y / haloScale,
|
|
|
|
);
|
2025-04-06 10:13:10 +02:00
|
|
|
});
|
|
|
|
|
2025-04-18 21:17:32 +02:00
|
|
|
startWork("render:halo:scale_up");
|
2025-03-29 11:24:45 +01:00
|
|
|
ctx.globalAlpha = 1;
|
2025-03-29 21:28:05 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-04-07 16:52:42 +02:00
|
|
|
|
|
|
|
ctx.imageSmoothingQuality = "high";
|
2025-04-18 21:17:32 +02:00
|
|
|
ctx.imageSmoothingEnabled = isOptionOn("smooth_lighting") || false;
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.drawImage(haloCanvas, 0, 0, width, height);
|
2025-04-07 16:52:42 +02:00
|
|
|
ctx.imageSmoothingEnabled = false;
|
2025-03-25 08:22:58 +01:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo:pattern");
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
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;
|
2025-03-29 11:24:45 +01:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
bgctx.globalCompositeOperation = "source-over";
|
|
|
|
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 = "#FFFFFF";
|
|
|
|
bgctx.font = "20px Courier";
|
|
|
|
bgctx.fillText(
|
|
|
|
pageSource.slice(
|
|
|
|
start + i * lineWidth,
|
|
|
|
start + (i + 1) * lineWidth,
|
|
|
|
),
|
|
|
|
0,
|
|
|
|
i * 20,
|
|
|
|
gameState.canvasWidth,
|
2025-04-06 10:13:10 +02:00
|
|
|
);
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const pattern = ctx.createPattern(background, "repeat");
|
|
|
|
if (pattern) {
|
|
|
|
bgctx.globalCompositeOperation = "screen";
|
|
|
|
bgctx.fillStyle = pattern;
|
|
|
|
bgctx.fillRect(0, 0, width, height);
|
|
|
|
}
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
ctx.globalCompositeOperation = "darken";
|
|
|
|
ctx.drawImage(backgroundCanvas, 0, 0);
|
|
|
|
} else {
|
|
|
|
// Background not loaded yes
|
|
|
|
ctx.fillStyle = "#000";
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
}
|
|
|
|
} else {
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:halo-basic");
|
2025-04-04 09:45:35 +02:00
|
|
|
ctx.globalAlpha = 1;
|
2025-03-28 19:40:59 +01:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-04-06 15:38:30 +02:00
|
|
|
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);
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:explosionshake");
|
2025-04-06 15:38:30 +02: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,
|
2025-04-06 10:13:10 +02:00
|
|
|
);
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:coins");
|
2025-04-06 15:38:30 +02:00
|
|
|
// Coins
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
forEachLiveOne(gameState.coins, (coin) => {
|
|
|
|
const color = getCoinRenderColor(gameState, coin);
|
2025-04-10 21:40:45 +02:00
|
|
|
const hollow = gameState.perks.metamorphosis && !coin.metamorphosisPoints;
|
2025-04-06 10:13:10 +02:00
|
|
|
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-04-06 15:38:30 +02:00
|
|
|
drawCoin(
|
|
|
|
ctx,
|
2025-04-10 21:40:45 +02:00
|
|
|
hollow ? "transparent" : color,
|
2025-04-06 15:38:30 +02:00
|
|
|
coin.size,
|
|
|
|
coin.x,
|
|
|
|
coin.y,
|
2025-04-09 09:24:15 +02:00
|
|
|
// Red border around coins with asceticism
|
2025-04-06 15:38:30 +02:00
|
|
|
(hasCombo && gameState.perks.asceticism && "#FF0000") ||
|
2025-04-09 09:24:15 +02:00
|
|
|
// Gold coins
|
|
|
|
// (color === "#ffd300" && "#ffd300") ||
|
2025-04-10 21:40:45 +02:00
|
|
|
(hollow && color) ||
|
2025-04-06 15:38:30 +02:00
|
|
|
gameState.level.color,
|
|
|
|
coin.a,
|
|
|
|
);
|
|
|
|
});
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:ball shade");
|
2025-04-06 15:38:30 +02:00
|
|
|
// Black shadow around balls
|
|
|
|
if (!isOptionOn("basic")) {
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
gameState.balls.forEach((ball) => {
|
2025-04-10 14:49:28 +02:00
|
|
|
ctx.globalAlpha =
|
|
|
|
Math.min(0.8, liveCount(gameState.coins) / 20) *
|
|
|
|
(1 - ballTransparency(ball, gameState));
|
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
drawBall(
|
|
|
|
ctx,
|
|
|
|
level.color || "#000",
|
|
|
|
gameState.ballSize * 6,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
);
|
|
|
|
});
|
|
|
|
}
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:bricks");
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
renderAllBricks();
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:lights");
|
2025-04-06 15:38:30 +02:00
|
|
|
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(
|
|
|
|
gameState,
|
|
|
|
ctx,
|
|
|
|
color,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
-1,
|
|
|
|
gameState.perks.clairvoyant >= 2,
|
|
|
|
);
|
|
|
|
});
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:texts");
|
2025-04-06 15:38:30 +02:00
|
|
|
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);
|
|
|
|
});
|
2025-03-29 11:24:45 +01:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:particles");
|
2025-04-06 15:38:30 +02:00
|
|
|
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);
|
|
|
|
});
|
2025-04-15 21:25:27 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:extra_life");
|
2025-04-06 15:38:30 +02:00
|
|
|
if (gameState.perks.extra_life) {
|
2025-04-06 10:13:10 +02:00
|
|
|
ctx.globalAlpha = 1;
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
ctx.fillStyle = gameState.puckColor;
|
|
|
|
for (let i = 0; i < gameState.perks.extra_life; i++) {
|
|
|
|
ctx.fillRect(
|
2025-04-11 20:34:51 +02:00
|
|
|
gameState.offsetXRoundedDown,
|
2025-04-06 15:38:30 +02:00
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i,
|
2025-04-11 20:34:51 +02:00
|
|
|
gameState.gameZoneWidthRoundedUp,
|
2025-04-06 10:13:10 +02:00
|
|
|
1,
|
2025-04-06 15:38:30 +02:00
|
|
|
);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:balls");
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
gameState.balls.forEach((ball) => {
|
|
|
|
const drawingColor = gameState.ballsColor;
|
2025-04-10 14:49:28 +02:00
|
|
|
const ballAlpha = 1 - ballTransparency(ball, gameState);
|
|
|
|
ctx.globalAlpha = ballAlpha;
|
2025-04-06 15:38:30 +02:00
|
|
|
// The white border around is to distinguish colored balls from coins/bg
|
|
|
|
drawBall(
|
|
|
|
ctx,
|
|
|
|
drawingColor,
|
|
|
|
gameState.ballSize,
|
|
|
|
ball.x,
|
|
|
|
ball.y,
|
|
|
|
gameState.puckColor,
|
2025-03-28 19:40:59 +01:00
|
|
|
);
|
2025-03-25 08:22:58 +01:00
|
|
|
|
2025-04-06 10:13:10 +02:00
|
|
|
if (
|
2025-04-06 15:38:30 +02:00
|
|
|
telekinesisEffectRate(gameState, ball) ||
|
|
|
|
yoyoEffectRate(gameState, ball)
|
2025-04-06 10:13:10 +02:00
|
|
|
) {
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.beginPath();
|
|
|
|
ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight);
|
2025-04-10 14:49:28 +02:00
|
|
|
ctx.globalAlpha =
|
|
|
|
Math.max(
|
|
|
|
telekinesisEffectRate(gameState, ball),
|
|
|
|
yoyoEffectRate(gameState, ball),
|
|
|
|
) * ballAlpha;
|
2025-04-06 15:38:30 +02:00
|
|
|
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);
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-04 12:07:24 +02:00
|
|
|
ctx.globalAlpha = 1;
|
2025-04-06 15:38:30 +02:00
|
|
|
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();
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
});
|
2025-04-15 21:25:27 +02:00
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:puck");
|
2025-04-06 15:38:30 +02:00
|
|
|
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,
|
|
|
|
);
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:combotext");
|
2025-04-06 15:38:30 +02:00
|
|
|
if (gameState.combo > 1) {
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-04-16 09:26:10 +02:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
2025-04-06 15:38:30 +02: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;
|
2025-04-16 09:26:10 +02:00
|
|
|
|
|
|
|
ctx.globalAlpha = gameState.combo > baseCombo(gameState) ? 1 : 0.3;
|
2025-04-06 15:38:30 +02:00
|
|
|
if (totalWidth < gameState.puckWidth) {
|
|
|
|
drawText(
|
|
|
|
ctx,
|
|
|
|
comboText,
|
|
|
|
"#000",
|
|
|
|
gameState.puckHeight,
|
|
|
|
left + gameState.coinSize * 1.5,
|
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
|
|
|
true,
|
|
|
|
);
|
2025-04-16 09:26:10 +02:00
|
|
|
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
drawCoin(
|
|
|
|
ctx,
|
|
|
|
"#ffd300",
|
|
|
|
gameState.coinSize,
|
|
|
|
left + gameState.coinSize / 2,
|
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
|
|
|
"#ffd300",
|
|
|
|
0,
|
|
|
|
);
|
2025-04-06 15:38:30 +02:00
|
|
|
} else {
|
|
|
|
drawText(
|
|
|
|
ctx,
|
|
|
|
comboTextWidth > gameState.puckWidth
|
|
|
|
? gameState.combo.toString()
|
|
|
|
: comboText,
|
|
|
|
"#000",
|
|
|
|
comboTextWidth > gameState.puckWidth ? 12 : 20,
|
|
|
|
gameState.puckPosition,
|
|
|
|
gameState.gameZoneHeight - gameState.puckHeight / 2,
|
|
|
|
false,
|
|
|
|
);
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
2025-04-16 09:26:10 +02:00
|
|
|
startWork("render:borders");
|
2025-04-06 15:38:30 +02:00
|
|
|
// Borders
|
|
|
|
ctx.globalCompositeOperation = "source-over";
|
2025-04-11 20:34:51 +02:00
|
|
|
ctx.globalAlpha = 1;
|
2025-04-06 15:38:30 +02:00
|
|
|
|
|
|
|
let redLeftSide =
|
2025-04-11 20:34:51 +02:00
|
|
|
hasCombo && (gameState.perks.left_is_lava || gameState.perks.trampoline);
|
2025-04-06 15:38:30 +02:00
|
|
|
let redRightSide =
|
2025-04-11 20:34:51 +02:00
|
|
|
hasCombo && (gameState.perks.right_is_lava || gameState.perks.trampoline);
|
2025-04-06 15:38:30 +02:00
|
|
|
let redTop =
|
2025-04-11 20:34:51 +02:00
|
|
|
hasCombo && (gameState.perks.top_is_lava || gameState.perks.trampoline);
|
2025-04-11 20:34:11 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
if (gameState.offsetXRoundedDown) {
|
|
|
|
// draw outside of gaming area to avoid capturing borders in recordings
|
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
(redLeftSide && "#FF0000") || "#FFFFFF",
|
2025-04-11 20:34:11 +02:00
|
|
|
gameState.offsetXRoundedDown - 1,
|
2025-04-06 15:38:30 +02:00
|
|
|
0,
|
2025-04-11 20:34:11 +02:00
|
|
|
gameState.offsetXRoundedDown - 1,
|
2025-04-11 20:34:51 +02:00
|
|
|
height,
|
|
|
|
1,
|
2025-04-06 15:38:30 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
(redRightSide && "#FF0000") || "#FFFFFF",
|
2025-04-11 20:34:11 +02:00
|
|
|
width - gameState.offsetXRoundedDown + 1,
|
2025-04-06 15:38:30 +02:00
|
|
|
0,
|
2025-04-11 20:34:11 +02:00
|
|
|
width - gameState.offsetXRoundedDown + 1,
|
2025-04-06 15:38:30 +02:00
|
|
|
height,
|
2025-04-11 20:34:51 +02:00
|
|
|
1,
|
2025-04-06 15:38:30 +02:00
|
|
|
);
|
|
|
|
} else {
|
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
(redLeftSide && "#FF0000") || "",
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
0,
|
|
|
|
height,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
|
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
(redRightSide && "#FF0000") || "",
|
|
|
|
width - 1,
|
|
|
|
0,
|
|
|
|
width - 1,
|
|
|
|
height,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
if (redTop)
|
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
"#FF0000",
|
2025-04-11 20:34:51 +02:00
|
|
|
gameState.offsetXRoundedDown,
|
2025-04-06 15:38:30 +02:00
|
|
|
1,
|
2025-04-11 20:34:51 +02:00
|
|
|
width - gameState.offsetXRoundedDown,
|
2025-04-06 15:38:30 +02:00
|
|
|
1,
|
|
|
|
1,
|
|
|
|
);
|
|
|
|
|
2025-04-16 09:26:10 +02:00
|
|
|
startWork("render:bottom_line");
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalAlpha = 1;
|
2025-04-18 21:17:32 +02:00
|
|
|
const corner = getCornerOffset(gameState);
|
2025-04-06 15:38:30 +02:00
|
|
|
drawStraightLine(
|
|
|
|
ctx,
|
|
|
|
gameState,
|
|
|
|
(hasCombo && gameState.perks.compound_interest && "#FF0000") ||
|
2025-04-18 22:06:16 +02:00
|
|
|
(isOptionOn("mobile-mode") && "#666666") ||
|
|
|
|
(corner && "#666666") ||
|
2025-04-06 15:38:30 +02:00
|
|
|
"",
|
2025-04-18 21:17:32 +02:00
|
|
|
gameState.offsetXRoundedDown - corner,
|
|
|
|
gameState.gameZoneHeight - 1,
|
|
|
|
width - gameState.offsetXRoundedDown + corner,
|
|
|
|
gameState.gameZoneHeight - 1,
|
2025-04-06 15:38:30 +02:00
|
|
|
1,
|
|
|
|
);
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:contrast");
|
2025-04-06 15:38:30 +02:00
|
|
|
if (
|
|
|
|
!isOptionOn("basic") &&
|
|
|
|
isOptionOn("contrast") &&
|
|
|
|
level.svg &&
|
|
|
|
level.color === "#000000"
|
|
|
|
) {
|
2025-04-18 21:17:32 +02:00
|
|
|
ctx.imageSmoothingEnabled = isOptionOn("smooth_lighting") || false;
|
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
haloCanvasCtx.fillStyle = "#FFFFFF";
|
|
|
|
haloCanvasCtx.globalAlpha = 0.25;
|
|
|
|
haloCanvasCtx.globalCompositeOperation = "screen";
|
|
|
|
haloCanvasCtx.fillRect(0, 0, haloCanvas.width, haloCanvas.height);
|
|
|
|
ctx.globalAlpha = 1;
|
2025-04-07 15:25:58 +02:00
|
|
|
ctx.globalCompositeOperation = "overlay";
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.drawImage(haloCanvas, 0, 0, width, height);
|
2025-04-07 16:52:42 +02:00
|
|
|
|
|
|
|
ctx.imageSmoothingEnabled = false;
|
2025-04-06 15:38:30 +02:00
|
|
|
}
|
|
|
|
|
2025-04-16 09:26:10 +02:00
|
|
|
startWork("render:text_under_puck");
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalCompositeOperation = "source-over";
|
|
|
|
ctx.globalAlpha = 1;
|
2025-04-18 17:15:47 +02:00
|
|
|
if (isOptionOn("mobile-mode") && gameState.startParams.computer_controlled) {
|
2025-04-15 16:47:04 +02:00
|
|
|
drawText(
|
|
|
|
ctx,
|
|
|
|
"breakout.lecaro.me?autoplay",
|
|
|
|
gameState.puckColor,
|
|
|
|
gameState.puckHeight,
|
|
|
|
gameState.canvasWidth / 2,
|
|
|
|
gameState.gameZoneHeight +
|
|
|
|
(gameState.canvasHeight - gameState.gameZoneHeight) / 2,
|
|
|
|
);
|
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
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,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2025-04-15 16:47:04 +02:00
|
|
|
// if(isOptionOn('mobile-mode')) {
|
|
|
|
// ctx.globalCompositeOperation = "source-over";
|
|
|
|
// ctx.globalAlpha = 0.5;
|
|
|
|
// ctx.fillStyle = 'black'
|
|
|
|
// ctx.fillRect(0,gameState.gameZoneHeight, gameState.canvasWidth, gameState.canvasHeight-gameState.gameZoneHeight)
|
|
|
|
// }
|
|
|
|
// ctx.globalAlpha=1
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:askForWakeLock");
|
2025-04-15 16:47:04 +02:00
|
|
|
askForWakeLock(gameState);
|
|
|
|
|
2025-04-15 21:28:00 +02:00
|
|
|
startWork("render:resetTransform");
|
2025-04-06 15:38:30 +02:00
|
|
|
if (shaked) {
|
|
|
|
ctx.resetTransform();
|
|
|
|
}
|
2025-03-25 08:22:58 +01:00
|
|
|
}
|
|
|
|
|
2025-03-25 08:47:39 +01:00
|
|
|
function drawStraightLine(
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
gameState: GameState,
|
2025-04-18 22:06:16 +02:00
|
|
|
mode: "#FFFFFF" | "" | "#FF0000" | string,
|
2025-04-06 15:38:30 +02:00
|
|
|
x1,
|
|
|
|
y1,
|
|
|
|
x2,
|
|
|
|
y2,
|
|
|
|
alpha = 1,
|
2025-03-25 08:47:39 +01:00
|
|
|
) {
|
2025-04-18 22:06:16 +02:00
|
|
|
x1 = Math.round(x1);
|
|
|
|
y1 = Math.round(y1);
|
|
|
|
x2 = Math.round(x2);
|
|
|
|
y2 = Math.round(y2);
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalAlpha = alpha;
|
|
|
|
if (!mode) return;
|
2025-04-18 22:06:16 +02:00
|
|
|
ctx.strokeStyle = mode;
|
2025-04-06 15:38:30 +02:00
|
|
|
if (mode == "#FF0000") {
|
|
|
|
ctx.lineDashOffset = getDashOffset(gameState);
|
|
|
|
ctx.lineWidth = 2;
|
|
|
|
ctx.setLineDash(redBorderDash);
|
|
|
|
} else {
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
}
|
|
|
|
ctx.beginPath();
|
|
|
|
ctx.moveTo(x1, y1);
|
|
|
|
ctx.lineTo(x2, y2);
|
|
|
|
ctx.stroke();
|
|
|
|
if (mode == "#FF0000") {
|
|
|
|
ctx.setLineDash(emptyArray);
|
|
|
|
ctx.lineWidth = 1;
|
|
|
|
}
|
|
|
|
ctx.globalAlpha = 1;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let cachedBricksRender = document.createElement("canvas");
|
|
|
|
let cachedBricksRenderKey = "";
|
|
|
|
|
|
|
|
export function renderAllBricks() {
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
|
|
|
|
const hasCombo = gameState.combo > baseCombo(gameState);
|
|
|
|
|
|
|
|
const redBorderOnBricksWithWrongColor =
|
|
|
|
hasCombo && gameState.perks.picky_eater && isPickyEatingPossible(gameState);
|
|
|
|
|
|
|
|
const redColorOnAllBricks = hasCombo && isMovingWhilePassiveIncome(gameState);
|
|
|
|
|
|
|
|
const redRowReach = reachRedRowIndex(gameState);
|
|
|
|
const { clairvoyant } = gameState.perks;
|
|
|
|
let offset = getDashOffset(gameState);
|
|
|
|
if (
|
|
|
|
!(
|
|
|
|
redBorderOnBricksWithWrongColor ||
|
|
|
|
redColorOnAllBricks ||
|
|
|
|
redRowReach !== -1 ||
|
|
|
|
gameState.perks.zen
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
offset = 0;
|
|
|
|
}
|
|
|
|
|
|
|
|
const clairVoyance =
|
|
|
|
clairvoyant && gameState.brickHP.reduce((a, b) => a + b, 0);
|
|
|
|
|
|
|
|
const newKey =
|
|
|
|
gameState.gameZoneWidth +
|
|
|
|
"_" +
|
|
|
|
gameState.bricks.join("_") +
|
|
|
|
bombSVG.complete +
|
|
|
|
"_" +
|
|
|
|
redRowReach +
|
|
|
|
"_" +
|
|
|
|
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 =
|
|
|
|
redRowReach === Math.floor(index / gameState.level.size);
|
|
|
|
|
|
|
|
let redBorder =
|
|
|
|
(gameState.ballsColor !== color &&
|
|
|
|
color !== "black" &&
|
|
|
|
redBorderOnBricksWithWrongColor) ||
|
|
|
|
(hasCombo && gameState.perks.zen && color === "black") ||
|
|
|
|
redBecauseOfReach ||
|
|
|
|
redColorOnAllBricks;
|
|
|
|
|
|
|
|
canctx.globalCompositeOperation = "source-over";
|
|
|
|
drawBrick(
|
|
|
|
gameState,
|
|
|
|
canctx,
|
|
|
|
color,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
redBorder ? offset : -1,
|
|
|
|
clairvoyant >= 2,
|
|
|
|
);
|
|
|
|
if (gameState.brickHP[index] > 1 && clairvoyant) {
|
|
|
|
canctx.globalCompositeOperation = "source-over";
|
|
|
|
drawText(
|
|
|
|
canctx,
|
|
|
|
gameState.brickHP[index].toString(),
|
|
|
|
clairvoyant >= 2 ? color : gameState.level.color,
|
|
|
|
gameState.puckHeight,
|
|
|
|
x,
|
|
|
|
y,
|
|
|
|
);
|
|
|
|
}
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
if (color === "black") {
|
|
|
|
canctx.globalCompositeOperation = "source-over";
|
|
|
|
drawIMG(canctx, bombSVG, gameState.brickWidth, x, y);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
2025-03-25 08:22:58 +01:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx.drawImage(cachedBricksRender, gameState.offsetX, 0);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
let cachedGraphics: { [k: string]: HTMLCanvasElement } = {};
|
|
|
|
|
|
|
|
export function drawPuck(
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
puckWidth: number,
|
|
|
|
puckHeight: number,
|
|
|
|
yOffset = 0,
|
|
|
|
concave_puck: number,
|
|
|
|
redBorderOffset: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02:00
|
|
|
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);
|
|
|
|
}
|
2025-03-25 08:22:58 +01:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
canctx.fill();
|
2025-03-28 19:40:59 +01:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
if (redBorderOffset !== -1) {
|
|
|
|
canctx.strokeStyle = "#FF0000";
|
|
|
|
canctx.lineWidth = 4;
|
|
|
|
canctx.setLineDash(redBorderDash);
|
|
|
|
canctx.lineDashOffset = redBorderOffset;
|
|
|
|
canctx.stroke();
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
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-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
width: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
borderColor = "",
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02: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-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02: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-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
size: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
borderColor: colorString,
|
|
|
|
rawAngle: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02:00
|
|
|
const angle =
|
|
|
|
((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) %
|
|
|
|
angles;
|
|
|
|
const key =
|
|
|
|
"coin with halo" +
|
|
|
|
"_" +
|
|
|
|
color +
|
|
|
|
"_" +
|
|
|
|
size +
|
|
|
|
"_" +
|
|
|
|
borderColor +
|
|
|
|
"_" +
|
|
|
|
(color === "#ffd300" ? 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 == "#FF0000") {
|
|
|
|
canctx.lineWidth = 2;
|
|
|
|
canctx.setLineDash(redBorderDash);
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-04-10 21:40:45 +02:00
|
|
|
if (color === "transparent") {
|
|
|
|
canctx.lineWidth = 2;
|
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
canctx.stroke();
|
|
|
|
|
|
|
|
if (color === "#ffd300") {
|
|
|
|
// 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),
|
|
|
|
);
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
export function drawFuzzyBall(
|
2025-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
width: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02:00
|
|
|
const key = "fuzzy-circle" + color + "_" + width;
|
2025-04-09 11:28:32 +02:00
|
|
|
if (!color?.startsWith("#")) debugger;
|
2025-04-09 09:24:15 +02:00
|
|
|
|
2025-04-06 15:38:30 +02:00
|
|
|
const size = Math.round(width * 3);
|
2025-04-09 09:24:15 +02:00
|
|
|
if (!size || isNaN(size)) {
|
|
|
|
debugger;
|
2025-04-09 11:28:32 +02:00
|
|
|
return;
|
2025-04-09 09:24:15 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
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-04-06 15:38:30 +02:00
|
|
|
gradient.addColorStop(0, color);
|
|
|
|
gradient.addColorStop(0.3, color + "88");
|
|
|
|
gradient.addColorStop(0.6, color + "22");
|
|
|
|
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-04-06 15:38:30 +02:00
|
|
|
gameState: GameState,
|
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
color: colorString,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
offset: number = 0,
|
|
|
|
borderOnly: boolean,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02: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 +
|
|
|
|
"_" +
|
|
|
|
"_" +
|
|
|
|
width +
|
|
|
|
"_" +
|
|
|
|
height +
|
|
|
|
"_" +
|
|
|
|
offset +
|
|
|
|
"_" +
|
|
|
|
borderOnly +
|
2025-04-09 11:28:32 +02:00
|
|
|
"_";
|
2025-04-06 15:38:30 +02:00
|
|
|
|
|
|
|
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;
|
2025-04-09 09:24:15 +02:00
|
|
|
canctx.strokeStyle = (offset !== -1 && "#FF000033") || color;
|
2025-04-06 15:38:30 +02:00
|
|
|
canctx.lineJoin = "round";
|
2025-04-09 11:28:32 +02:00
|
|
|
canctx.lineWidth = bord;
|
2025-04-06 15:38:30 +02:00
|
|
|
roundRect(
|
|
|
|
canctx,
|
|
|
|
bord / 2,
|
|
|
|
bord / 2,
|
|
|
|
width - bord,
|
|
|
|
height - bord,
|
|
|
|
cornerRadius,
|
|
|
|
);
|
|
|
|
if (!borderOnly) {
|
|
|
|
canctx.fill();
|
2025-04-06 10:13:10 +02:00
|
|
|
}
|
2025-04-06 15:38:30 +02:00
|
|
|
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-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
width: number,
|
|
|
|
height: number,
|
|
|
|
radius: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02: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-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
img: HTMLImageElement,
|
|
|
|
size: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02: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-04-06 15:38:30 +02:00
|
|
|
ctx: CanvasRenderingContext2D,
|
|
|
|
text: string,
|
|
|
|
color: colorString,
|
|
|
|
fontSize: number,
|
|
|
|
x: number,
|
|
|
|
y: number,
|
|
|
|
left = false,
|
2025-03-16 14:29:14 +01:00
|
|
|
) {
|
2025-04-06 15:38:30 +02: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(
|
2025-04-06 15:38:30 +02:00
|
|
|
"score",
|
2025-03-16 17:45:29 +01:00
|
|
|
) as HTMLButtonElement;
|
|
|
|
const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement;
|
2025-03-25 08:22:58 +01:00
|
|
|
|
2025-03-28 11:58:58 +01:00
|
|
|
const emptyArray = [];
|
2025-03-25 08:47:39 +01:00
|
|
|
const redBorderDash = [5, 5];
|
2025-03-25 08:22:58 +01:00
|
|
|
|
|
|
|
export function getDashOffset(gameState: GameState) {
|
2025-04-06 15:38:30 +02:00
|
|
|
if (isOptionOn("basic")) {
|
|
|
|
return 0;
|
|
|
|
}
|
|
|
|
return Math.floor(((gameState.levelTime % 500) / 500) * 10) % 10;
|
2025-03-25 08:47:39 +01:00
|
|
|
}
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-04-15 16:47:04 +02:00
|
|
|
let wakeLock = null,
|
|
|
|
wakeLockPending = false;
|
|
|
|
function askForWakeLock(gameState: GameState) {
|
2025-04-18 21:17:32 +02:00
|
|
|
if (
|
|
|
|
gameState.startParams.computer_controlled &&
|
|
|
|
!wakeLock &&
|
|
|
|
!wakeLockPending
|
|
|
|
) {
|
2025-04-15 16:47:04 +02:00
|
|
|
wakeLockPending = true;
|
|
|
|
try {
|
|
|
|
navigator.wakeLock.request("screen").then((lock) => {
|
|
|
|
wakeLock = lock;
|
|
|
|
wakeLockPending = false;
|
|
|
|
lock.addEventListener("release", () => {
|
|
|
|
// the wake lock has been released
|
|
|
|
wakeLock = null;
|
|
|
|
});
|
|
|
|
});
|
|
|
|
} catch (e) {
|
|
|
|
console.warn("askForWakeLock error", e);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|