2025-03-16 17:45:29 +01:00
|
|
|
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
|
2025-03-16 14:29:14 +01:00
|
|
|
import {
|
2025-03-16 17:45:29 +01:00
|
|
|
Ball,
|
|
|
|
Coin,
|
|
|
|
GameState,
|
|
|
|
OptionId,
|
|
|
|
PerkId,
|
|
|
|
RunParams,
|
|
|
|
Upgrade,
|
|
|
|
} from "./types";
|
|
|
|
import { getAudioContext } from "./sounds";
|
|
|
|
import {
|
|
|
|
currentLevelInfo,
|
|
|
|
getRowColIndex,
|
|
|
|
max_levels,
|
|
|
|
pickedUpgradesHTMl,
|
|
|
|
} from "./game_utils";
|
|
|
|
|
|
|
|
import "./PWA/sw_loader";
|
|
|
|
import { getCurrentLang, t } from "./i18n/i18n";
|
|
|
|
import { getSettingValue, getTotalScore, setSettingValue } from "./settings";
|
|
|
|
import {
|
|
|
|
gameStateTick,
|
|
|
|
normalizeGameState,
|
|
|
|
pickRandomUpgrades,
|
|
|
|
putBallsAtPuck,
|
|
|
|
resetBalls,
|
|
|
|
resetCombo,
|
|
|
|
setLevel,
|
|
|
|
setMousePos,
|
2025-03-16 14:29:14 +01:00
|
|
|
} from "./gameStateMutators";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
|
|
|
backgroundCanvas,
|
|
|
|
bombSVG,
|
|
|
|
ctx,
|
|
|
|
gameCanvas,
|
|
|
|
render,
|
|
|
|
scoreDisplay,
|
|
|
|
} from "./render";
|
|
|
|
import {
|
|
|
|
pauseRecording,
|
|
|
|
recordOneFrame,
|
|
|
|
resumeRecording,
|
|
|
|
startRecordingGame,
|
|
|
|
} from "./recording";
|
|
|
|
import { newGameState } from "./newGameState";
|
|
|
|
import {
|
|
|
|
alertsOpen,
|
|
|
|
asyncAlert,
|
|
|
|
AsyncAlertAction,
|
|
|
|
closeModal,
|
|
|
|
} from "./asyncAlert";
|
|
|
|
import { isOptionOn, options, toggleOption } from "./options";
|
2025-03-05 22:10:17 +01:00
|
|
|
|
2025-03-06 16:46:25 +01:00
|
|
|
bombSVG.src =
|
2025-03-16 17:45:29 +01: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-02-15 19:21:00 +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-14 11:59:49 +01:00
|
|
|
export function play() {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.running) return;
|
|
|
|
gameState.running = true;
|
2025-03-13 14:14:00 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
startRecordingGame(gameState);
|
|
|
|
getAudioContext()?.resume();
|
|
|
|
resumeRecording();
|
|
|
|
document.body.className = gameState.running ? " running " : " paused ";
|
2025-02-16 21:21:12 +01:00
|
|
|
}
|
2025-02-17 00:49:03 +01:00
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function pause(playerAskedForPause: boolean) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.running) return;
|
|
|
|
if (gameState.pauseTimeout) return;
|
|
|
|
|
|
|
|
gameState.pauseTimeout = setTimeout(
|
|
|
|
() => {
|
|
|
|
gameState.running = false;
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
if (!gameState.running) getAudioContext()?.suspend();
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
pauseRecording();
|
|
|
|
gameState.pauseTimeout = null;
|
|
|
|
document.body.className = gameState.running ? " running " : " paused ";
|
|
|
|
scoreDisplay.className = "";
|
2025-03-16 20:11:27 +01:00
|
|
|
gameState.needsRender = true;
|
2025-03-16 17:45:29 +01:00
|
|
|
},
|
|
|
|
Math.min(Math.max(0, gameState.pauseUsesDuringRun - 5) * 50, 500),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (playerAskedForPause) {
|
|
|
|
// Pausing many times in a run will make pause slower
|
|
|
|
gameState.pauseUsesDuringRun++;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (document.exitPointerLock) {
|
|
|
|
document.exitPointerLock();
|
|
|
|
}
|
2025-03-12 15:15:30 +01:00
|
|
|
}
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
export const fitSize = () => {
|
2025-03-16 17:45:29 +01:00
|
|
|
const { width, height } = gameCanvas.getBoundingClientRect();
|
|
|
|
gameState.canvasWidth = width;
|
|
|
|
gameState.canvasHeight = height;
|
|
|
|
gameCanvas.width = width;
|
|
|
|
gameCanvas.height = height;
|
|
|
|
ctx.fillStyle = currentLevelInfo(gameState)?.color || "black";
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
backgroundCanvas.width = width;
|
|
|
|
backgroundCanvas.height = height;
|
|
|
|
|
|
|
|
gameState.gameZoneHeight = isOptionOn("mobile-mode")
|
|
|
|
? (height * 80) / 100
|
|
|
|
: height;
|
|
|
|
const baseWidth = Math.round(
|
|
|
|
Math.min(gameState.canvasWidth, gameState.gameZoneHeight * 0.73),
|
|
|
|
);
|
|
|
|
gameState.brickWidth = Math.floor(baseWidth / gameState.gridSize / 2) * 2;
|
|
|
|
gameState.gameZoneWidth = gameState.brickWidth * gameState.gridSize;
|
|
|
|
gameState.offsetX = Math.floor(
|
|
|
|
(gameState.canvasWidth - gameState.gameZoneWidth) / 2,
|
|
|
|
);
|
|
|
|
gameState.offsetXRoundedDown = gameState.offsetX;
|
|
|
|
if (gameState.offsetX < gameState.ballSize) gameState.offsetXRoundedDown = 0;
|
|
|
|
gameState.gameZoneWidthRoundedUp = width - 2 * gameState.offsetXRoundedDown;
|
|
|
|
backgroundCanvas.title = "resized";
|
|
|
|
// Ensure puck stays within bounds
|
|
|
|
setMousePos(gameState, gameState.puckPosition);
|
|
|
|
gameState.coins = [];
|
|
|
|
gameState.flashes = [];
|
|
|
|
pause(true);
|
|
|
|
putBallsAtPuck(gameState);
|
|
|
|
// For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
|
|
|
|
document.documentElement.style.setProperty(
|
|
|
|
"--vh",
|
|
|
|
`${window.innerHeight * 0.01}px`,
|
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
};
|
|
|
|
window.addEventListener("resize", fitSize);
|
2025-03-01 21:59:41 +01:00
|
|
|
window.addEventListener("fullscreenchange", fitSize);
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-11 13:56:42 +01:00
|
|
|
setInterval(() => {
|
2025-03-16 17:45:29 +01:00
|
|
|
// Sometimes, the page changes size without triggering the event (when switching to fullscreen, closing debug panel...)
|
|
|
|
const { width, height } = gameCanvas.getBoundingClientRect();
|
|
|
|
if (width !== gameState.canvasWidth || height !== gameState.canvasHeight)
|
|
|
|
fitSize();
|
2025-03-11 13:56:42 +01:00
|
|
|
}, 1000);
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
export async function openUpgradesPicker(gameState: GameState) {
|
|
|
|
const catchRate =
|
|
|
|
(gameState.score - gameState.levelStartScore) /
|
|
|
|
(gameState.levelSpawnedCoins || 1);
|
|
|
|
|
|
|
|
let repeats = 1;
|
|
|
|
let choices = 3;
|
|
|
|
|
|
|
|
let timeGain = "",
|
|
|
|
catchGain = "",
|
|
|
|
wallHitsGain = "",
|
|
|
|
missesGain = "";
|
|
|
|
|
|
|
|
if (gameState.levelWallBounces == 0) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
wallHitsGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelWallBounces < 5) {
|
|
|
|
choices++;
|
|
|
|
wallHitsGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (gameState.levelTime < 30 * 1000) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
timeGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelTime < 60 * 1000) {
|
|
|
|
choices++;
|
|
|
|
timeGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (catchRate === 1) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
catchGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (catchRate > 0.9) {
|
|
|
|
choices++;
|
|
|
|
catchGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (gameState.levelMisses === 0) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
missesGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelMisses <= 3) {
|
|
|
|
choices++;
|
|
|
|
missesGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
|
|
|
|
while (repeats--) {
|
|
|
|
const actions = pickRandomUpgrades(
|
|
|
|
gameState,
|
|
|
|
choices +
|
|
|
|
gameState.perks.one_more_choice -
|
|
|
|
gameState.perks.instant_upgrade,
|
|
|
|
);
|
|
|
|
if (!actions.length) break;
|
|
|
|
let textAfterButtons = `
|
|
|
|
<p>${t("level_up.after_buttons", {
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
2025-03-15 21:29:38 +01:00
|
|
|
})} </p>
|
2025-03-16 14:29:14 +01:00
|
|
|
<p>${pickedUpgradesHTMl(gameState)}</p>
|
2025-03-14 11:59:49 +01:00
|
|
|
<div id="level-recording-container"></div>
|
|
|
|
|
|
|
|
`;
|
2025-03-13 08:53:02 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
const compliment =
|
|
|
|
(timeGain &&
|
|
|
|
catchGain &&
|
|
|
|
missesGain &&
|
|
|
|
wallHitsGain &&
|
|
|
|
t("level_up.compliment_perfect")) ||
|
|
|
|
((timeGain || catchGain || missesGain || wallHitsGain) &&
|
|
|
|
t("level_up.compliment_good")) ||
|
|
|
|
t("level_up.compliment_advice");
|
|
|
|
|
|
|
|
const upgradeId = (await asyncAlert<PerkId>({
|
|
|
|
title:
|
|
|
|
t("level_up.pick_upgrade_title") +
|
|
|
|
(repeats ? " (" + (repeats + 1) + ")" : ""),
|
|
|
|
actions,
|
|
|
|
text: `<p>${t("level_up.before_buttons", {
|
|
|
|
score: gameState.score - gameState.levelStartScore,
|
|
|
|
catchGain,
|
|
|
|
levelSpawnedCoins: gameState.levelSpawnedCoins,
|
|
|
|
time: Math.round(gameState.levelTime / 1000),
|
|
|
|
timeGain,
|
|
|
|
levelMisses: gameState.levelMisses,
|
|
|
|
missesGain,
|
|
|
|
levelWallBounces: gameState.levelWallBounces,
|
|
|
|
wallHitsGain,
|
|
|
|
compliment,
|
|
|
|
})}
|
2025-03-14 11:59:49 +01:00
|
|
|
</p>`,
|
2025-03-16 17:45:29 +01:00
|
|
|
allowClose: false,
|
|
|
|
textAfterButtons,
|
|
|
|
})) as PerkId;
|
2025-03-15 10:34:01 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
gameState.perks[upgradeId]++;
|
|
|
|
if (upgradeId === "instant_upgrade") {
|
|
|
|
repeats += 2;
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
|
|
|
|
gameState.runStatistics.upgrades_picked++;
|
|
|
|
}
|
|
|
|
resetCombo(gameState, undefined, undefined);
|
|
|
|
resetBalls(gameState);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mouseup", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
if (gameState.running) {
|
|
|
|
pause(true);
|
|
|
|
} else {
|
|
|
|
play();
|
|
|
|
if (isOptionOn("pointerLock")) {
|
|
|
|
gameCanvas.requestPointerLock();
|
2025-02-16 21:21:12 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mousemove", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.pointerLockElement === gameCanvas) {
|
|
|
|
setMousePos(gameState, gameState.puckPosition + e.movementX);
|
|
|
|
} else {
|
|
|
|
setMousePos(gameState, e.x);
|
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchstart", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
if (!e.touches?.length) return;
|
|
|
|
setMousePos(gameState, e.touches[0].pageX);
|
|
|
|
play();
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchend", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
pause(true);
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchcancel", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
pause(true);
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchmove", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!e.touches?.length) return;
|
|
|
|
setMousePos(gameState, e.touches[0].pageX);
|
2025-03-16 14:29:14 +01:00
|
|
|
});
|
2025-03-12 15:15:30 +01:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function brickIndex(x: number, y: number) {
|
2025-03-16 17:45:29 +01:00
|
|
|
return getRowColIndex(
|
|
|
|
gameState,
|
|
|
|
Math.floor(y / gameState.brickWidth),
|
|
|
|
Math.floor((x - gameState.offsetX) / gameState.brickWidth),
|
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function hasBrick(index: number): number | undefined {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.bricks[index]) return index;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-15 10:34:01 +01:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function hitsSomething(x: number, y: number, radius: number) {
|
2025-03-16 17:45:29 +01:00
|
|
|
return (
|
|
|
|
hasBrick(brickIndex(x - radius, y - radius)) ??
|
|
|
|
hasBrick(brickIndex(x + radius, y - radius)) ??
|
|
|
|
hasBrick(brickIndex(x + radius, y + radius)) ??
|
|
|
|
hasBrick(brickIndex(x - radius, y + radius))
|
|
|
|
);
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function shouldPierceByColor(
|
2025-03-16 17:45:29 +01:00
|
|
|
vhit: number | undefined,
|
|
|
|
hhit: number | undefined,
|
|
|
|
chit: number | undefined,
|
2025-03-07 20:18:18 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.perks.pierce_color) return false;
|
|
|
|
if (
|
|
|
|
typeof vhit !== "undefined" &&
|
|
|
|
gameState.bricks[vhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof hhit !== "undefined" &&
|
|
|
|
gameState.bricks[hhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof chit !== "undefined" &&
|
|
|
|
gameState.bricks[chit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function coinBrickHitCheck(coin: Coin) {
|
2025-03-16 17:45:29 +01:00
|
|
|
// Make ball/coin bonce, and return bricks that were hit
|
|
|
|
const radius = coin.size / 2;
|
|
|
|
const { x, y, previousX, previousY } = coin;
|
|
|
|
|
|
|
|
const vhit = hitsSomething(previousX, y, radius);
|
|
|
|
const hhit = hitsSomething(x, previousY, radius);
|
|
|
|
const chit =
|
|
|
|
(typeof vhit == "undefined" &&
|
|
|
|
typeof hhit == "undefined" &&
|
|
|
|
hitsSomething(x, y, radius)) ||
|
|
|
|
undefined;
|
|
|
|
|
|
|
|
if (typeof vhit !== "undefined" || typeof chit !== "undefined") {
|
|
|
|
coin.y = coin.previousY;
|
|
|
|
coin.vy *= -1;
|
|
|
|
|
|
|
|
// Roll on corners
|
|
|
|
const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)];
|
|
|
|
const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)];
|
|
|
|
|
|
|
|
if (leftHit && !rightHit) {
|
|
|
|
coin.vx += 1;
|
|
|
|
coin.sa -= 1;
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!leftHit && rightHit) {
|
|
|
|
coin.vx -= 1;
|
|
|
|
coin.sa += 1;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
|
|
|
|
coin.x = coin.previousX;
|
|
|
|
coin.vx *= -1;
|
|
|
|
}
|
|
|
|
return vhit ?? hhit ?? chit;
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function bordersHitCheck(
|
2025-03-16 17:45:29 +01:00
|
|
|
coin: Coin | Ball,
|
|
|
|
radius: number,
|
|
|
|
delta: number,
|
2025-03-07 20:18:18 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (coin.destroyed) return;
|
|
|
|
coin.previousX = coin.x;
|
|
|
|
coin.previousY = coin.y;
|
|
|
|
coin.x += coin.vx * delta;
|
|
|
|
coin.y += coin.vy * delta;
|
|
|
|
coin.sx ||= 0;
|
|
|
|
coin.sy ||= 0;
|
|
|
|
coin.sx += coin.previousX - coin.x;
|
|
|
|
coin.sy += coin.previousY - coin.y;
|
|
|
|
coin.sx *= 0.9;
|
|
|
|
coin.sy *= 0.9;
|
|
|
|
|
|
|
|
if (gameState.perks.wind) {
|
|
|
|
coin.vx +=
|
|
|
|
((gameState.puckPosition -
|
|
|
|
(gameState.offsetX + gameState.gameZoneWidth / 2)) /
|
|
|
|
gameState.gameZoneWidth) *
|
|
|
|
gameState.perks.wind *
|
|
|
|
0.5;
|
|
|
|
}
|
|
|
|
|
|
|
|
let vhit = 0,
|
|
|
|
hhit = 0;
|
|
|
|
|
|
|
|
if (coin.x < gameState.offsetXRoundedDown + radius) {
|
|
|
|
coin.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
radius +
|
|
|
|
(gameState.offsetXRoundedDown + radius - coin.x);
|
|
|
|
coin.vx *= -1;
|
|
|
|
hhit = 1;
|
|
|
|
}
|
|
|
|
if (coin.y < radius) {
|
|
|
|
coin.y = radius + (radius - coin.y);
|
|
|
|
coin.vy *= -1;
|
|
|
|
vhit = 1;
|
|
|
|
}
|
|
|
|
if (coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius) {
|
|
|
|
coin.x =
|
|
|
|
gameState.canvasWidth -
|
|
|
|
gameState.offsetXRoundedDown -
|
|
|
|
radius -
|
|
|
|
(coin.x -
|
|
|
|
(gameState.canvasWidth - gameState.offsetXRoundedDown - radius));
|
|
|
|
coin.vx *= -1;
|
|
|
|
hhit = 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
return hhit + vhit * 2;
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function tick() {
|
2025-03-16 17:45:29 +01:00
|
|
|
const currentTick = performance.now();
|
|
|
|
const timeDeltaMs = currentTick - gameState.lastTick;
|
|
|
|
gameState.lastTick = currentTick;
|
2025-03-14 11:59:49 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
const frames = Math.min(4, timeDeltaMs / (1000 / 60));
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.keyboardPuckSpeed) {
|
|
|
|
setMousePos(
|
|
|
|
gameState,
|
|
|
|
gameState.puckPosition + gameState.keyboardPuckSpeed,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
|
|
|
|
normalizeGameState(gameState);
|
|
|
|
|
|
|
|
if (gameState.running) {
|
|
|
|
gameState.levelTime += timeDeltaMs;
|
|
|
|
gameState.runStatistics.runTime += timeDeltaMs;
|
|
|
|
gameStateTick(gameState, frames);
|
|
|
|
}
|
2025-03-16 20:11:27 +01:00
|
|
|
if (gameState.running || gameState.needsRender) {
|
|
|
|
gameState.needsRender = false;
|
2025-03-16 20:11:08 +01:00
|
|
|
render(gameState);
|
|
|
|
}
|
2025-03-16 20:11:27 +01:00
|
|
|
if (gameState.running) {
|
2025-03-16 20:11:08 +01:00
|
|
|
recordOneFrame(gameState);
|
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
requestAnimationFrame(tick);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener("visibilitychange", () => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.hidden) {
|
|
|
|
pause(true);
|
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
scoreDisplay.addEventListener("click", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
openScorePanel();
|
2025-02-27 18:56:04 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
document.addEventListener("visibilitychange", () => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.hidden) {
|
|
|
|
pause(true);
|
|
|
|
}
|
2025-03-15 10:34:01 +01:00
|
|
|
});
|
|
|
|
|
2025-03-01 14:20:27 +01:00
|
|
|
async function openScorePanel() {
|
2025-03-16 17:45:29 +01:00
|
|
|
pause(true);
|
|
|
|
const cb = await asyncAlert({
|
|
|
|
title: t("score_panel.title", {
|
|
|
|
score: gameState.score,
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
|
|
|
}),
|
|
|
|
text: `
|
2025-03-15 21:29:38 +01:00
|
|
|
${gameState.isCreativeModeRun ? "<p>${t('score_panel.test_run}</p>" : ""}
|
2025-03-16 17:45:29 +01:00
|
|
|
<p>${t("score_panel.upgrades_picked")}</p>
|
2025-03-16 14:29:14 +01:00
|
|
|
<p>${pickedUpgradesHTMl(gameState)}</p>
|
2025-03-06 16:46:25 +01:00
|
|
|
`,
|
2025-03-16 17:45:29 +01:00
|
|
|
allowClose: true,
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: t("score_panel.resume"),
|
|
|
|
help: t("score_panel.resume_help"),
|
|
|
|
value: () => {},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("score_panel.restart"),
|
|
|
|
help: t("score_panel.restart_help"),
|
|
|
|
value: () => {
|
|
|
|
restart({ levelToAvoid: currentLevelInfo(gameState).name });
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
|
|
|
}
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-10 15:05:48 +01:00
|
|
|
document.getElementById("menu")?.addEventListener("click", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
openSettingsPanel();
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
async function openSettingsPanel() {
|
2025-03-16 17:45:29 +01:00
|
|
|
pause(true);
|
|
|
|
|
|
|
|
const actions: AsyncAlertAction<() => void>[] = [
|
|
|
|
{
|
|
|
|
text: t("main_menu.resume"),
|
|
|
|
help: t("main_menu.resume_help"),
|
|
|
|
value() {},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("main_menu.unlocks"),
|
|
|
|
help: t("main_menu.unlocks_help"),
|
|
|
|
value() {
|
|
|
|
openUnlocksList();
|
|
|
|
},
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const key of Object.keys(options) as OptionId[]) {
|
|
|
|
if (options[key])
|
|
|
|
actions.push({
|
|
|
|
disabled: options[key].disabled(),
|
|
|
|
icon: isOptionOn(key)
|
|
|
|
? icons["icon:checkmark_checked"]
|
|
|
|
: icons["icon:checkmark_unchecked"],
|
|
|
|
text: options[key].name,
|
|
|
|
help: options[key].help,
|
|
|
|
value: () => {
|
|
|
|
toggleOption(key);
|
|
|
|
if (key === "mobile-mode") fitSize();
|
|
|
|
|
|
|
|
openSettingsPanel();
|
2025-03-14 11:59:49 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold));
|
|
|
|
|
|
|
|
if (document.fullscreenEnabled || document.webkitFullscreenEnabled) {
|
|
|
|
if (document.fullscreenElement !== null) {
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.fullscreen_exit"),
|
|
|
|
help: t("main_menu.fullscreen_exit_help"),
|
|
|
|
icon: icons["icon:exit_fullscreen"],
|
|
|
|
value() {
|
|
|
|
toggleFullScreen();
|
2025-03-15 21:29:38 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.fullscreen"),
|
|
|
|
help: t("main_menu.fullscreen_help"),
|
2025-03-15 21:29:38 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
icon: icons["icon:fullscreen"],
|
|
|
|
value() {
|
|
|
|
toggleFullScreen();
|
2025-03-15 21:29:38 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
actions.push({
|
|
|
|
text: t("sandbox.title"),
|
|
|
|
help:
|
|
|
|
getTotalScore() < creativeModeThreshold
|
|
|
|
? t("sandbox.unlocks_at", { score: creativeModeThreshold })
|
|
|
|
: t("sandbox.help"),
|
|
|
|
disabled: getTotalScore() < creativeModeThreshold,
|
|
|
|
async value() {
|
|
|
|
let creativeModePerks: Partial<{ [id in PerkId]: number }> =
|
|
|
|
getSettingValue("creativeModePerks", {}),
|
|
|
|
choice: "start" | Upgrade | void;
|
|
|
|
|
|
|
|
while (
|
|
|
|
(choice = await asyncAlert<"start" | Upgrade>({
|
|
|
|
title: t("sandbox.title"),
|
|
|
|
text: t("sandbox.instructions"),
|
|
|
|
actionsAsGrid: true,
|
|
|
|
actions: [
|
|
|
|
...upgrades.map((u) => ({
|
|
|
|
icon: u.icon,
|
|
|
|
text: u.name,
|
|
|
|
help: (creativeModePerks[u.id] || 0) + "/" + u.max,
|
|
|
|
value: u,
|
|
|
|
className: creativeModePerks[u.id]
|
|
|
|
? ""
|
|
|
|
: "grey-out-unless-hovered",
|
|
|
|
})),
|
|
|
|
{
|
|
|
|
text: t("sandbox.start"),
|
|
|
|
value: "start",
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}))
|
|
|
|
) {
|
|
|
|
if (choice === "start") {
|
|
|
|
setSettingValue("creativeModePerks", creativeModePerks);
|
|
|
|
restart({ perks: creativeModePerks });
|
|
|
|
|
|
|
|
break;
|
|
|
|
} else if (choice) {
|
|
|
|
creativeModePerks[choice.id] =
|
|
|
|
((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.reset"),
|
|
|
|
help: t("main_menu.reset_help"),
|
|
|
|
async value() {
|
|
|
|
if (
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.reset"),
|
|
|
|
text: t("main_menu.reset_instruction"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_confirm"),
|
|
|
|
value: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_cancel"),
|
|
|
|
value: false,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
})
|
|
|
|
) {
|
|
|
|
localStorage.clear();
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.language"),
|
|
|
|
help: t("main_menu.language_help"),
|
|
|
|
async value() {
|
|
|
|
const pick = await asyncAlert({
|
|
|
|
title: t("main_menu.language"),
|
|
|
|
text: t("main_menu.language_help"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: "English",
|
|
|
|
value: "en",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: "Français",
|
|
|
|
value: "fr",
|
|
|
|
},
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (pick && pick !== getCurrentLang() && (await confirmRestart())) {
|
|
|
|
setSettingValue("lang", pick);
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
const cb = await asyncAlert<() => void>({
|
|
|
|
title: t("main_menu.title"),
|
|
|
|
text: ``,
|
|
|
|
allowClose: true,
|
|
|
|
actions,
|
|
|
|
textAfterButtons: t("main_menu.footer_html", { appVersion }),
|
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
2025-03-16 20:11:27 +01:00
|
|
|
gameState.needsRender = true;
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-03-06 16:46:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async function openUnlocksList() {
|
2025-03-16 17:45:29 +01:00
|
|
|
const ts = getTotalScore();
|
|
|
|
const actions = [
|
|
|
|
...upgrades
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map(({ name, id, threshold, icon, fullHelp }) => ({
|
|
|
|
text: name,
|
|
|
|
help:
|
|
|
|
ts >= threshold ? fullHelp : t("unlocks.unlocks_at", { threshold }),
|
|
|
|
disabled: ts < threshold,
|
|
|
|
value: { perks: { [id]: 1 } } as RunParams,
|
|
|
|
icon,
|
|
|
|
})),
|
|
|
|
...allLevels
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map((l) => {
|
|
|
|
const available = ts >= l.threshold;
|
|
|
|
return {
|
|
|
|
text: l.name,
|
|
|
|
help: available
|
|
|
|
? t("unlocks.level_description", {
|
|
|
|
size: l.size,
|
|
|
|
bricks: l.bricks.filter((i) => i).length,
|
|
|
|
})
|
|
|
|
: t("unlocks.unlocks_at", { threshold: l.threshold }),
|
|
|
|
disabled: !available,
|
|
|
|
value: { level: l.name } as RunParams,
|
|
|
|
icon: icons[l.name],
|
|
|
|
};
|
|
|
|
}),
|
|
|
|
];
|
|
|
|
|
|
|
|
const percentUnlock = Math.round(
|
|
|
|
(actions.filter((a) => !a.disabled).length / actions.length) * 100,
|
|
|
|
);
|
|
|
|
const tryOn = await asyncAlert<RunParams>({
|
|
|
|
title: t("unlocks.title", { percentUnlock }),
|
|
|
|
text: `<p>${t("unlocks.intro", { ts })}
|
|
|
|
${percentUnlock < 100 ? t("unlocks.greyed_out_help") : ""}</p>
|
2025-03-06 16:46:25 +01:00
|
|
|
`,
|
2025-03-16 17:45:29 +01:00
|
|
|
textAfterButtons: `<p>
|
2025-03-14 11:59:49 +01:00
|
|
|
Your high score is ${gameState.highScore}.
|
2025-03-06 16:46:25 +01:00
|
|
|
Click an item above to start a run with it.
|
|
|
|
</p>`,
|
2025-03-16 17:45:29 +01:00
|
|
|
actions,
|
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (tryOn) {
|
|
|
|
if (await confirmRestart()) {
|
|
|
|
restart(tryOn);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 10:24:46 +01:00
|
|
|
export async function confirmRestart() {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.currentLevel) return true;
|
|
|
|
|
|
|
|
return asyncAlert({
|
|
|
|
title: t("confirmRestart.title"),
|
|
|
|
text: t("confirmRestart.text"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
value: true,
|
|
|
|
text: t("confirmRestart.yes"),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
value: false,
|
|
|
|
text: t("confirmRestart.no"),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
2025-03-16 10:24:46 +01:00
|
|
|
}
|
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function toggleFullScreen() {
|
2025-03-16 17:45:29 +01:00
|
|
|
try {
|
|
|
|
if (document.fullscreenElement !== null) {
|
|
|
|
if (document.exitFullscreen) {
|
|
|
|
document.exitFullscreen();
|
|
|
|
} else if (document.webkitCancelFullScreen) {
|
|
|
|
document.webkitCancelFullScreen();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const docel = document.documentElement;
|
|
|
|
if (docel.requestFullscreen) {
|
|
|
|
docel.requestFullscreen();
|
|
|
|
} else if (docel.webkitRequestFullscreen) {
|
|
|
|
docel.webkitRequestFullscreen();
|
|
|
|
}
|
2025-03-01 14:20:27 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} catch (e) {
|
|
|
|
console.warn(e);
|
|
|
|
}
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
|
|
|
|
2025-03-11 13:56:42 +01:00
|
|
|
const pressed: { [k: string]: number } = {
|
2025-03-16 17:45:29 +01:00
|
|
|
ArrowLeft: 0,
|
|
|
|
ArrowRight: 0,
|
|
|
|
Shift: 0,
|
2025-03-06 16:46:25 +01:00
|
|
|
};
|
2025-03-01 14:20:27 +01:00
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function setKeyPressed(key: string, on: 0 | 1) {
|
2025-03-16 17:45:29 +01:00
|
|
|
pressed[key] = on;
|
|
|
|
gameState.keyboardPuckSpeed =
|
|
|
|
((pressed.ArrowRight - pressed.ArrowLeft) *
|
|
|
|
(1 + pressed.Shift * 2) *
|
|
|
|
gameState.gameZoneWidth) /
|
|
|
|
50;
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-03-01 14:20:27 +01:00
|
|
|
|
2025-03-06 16:46:25 +01:00
|
|
|
document.addEventListener("keydown", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) {
|
|
|
|
toggleFullScreen();
|
|
|
|
} else if (e.key in pressed) {
|
|
|
|
setKeyPressed(e.key, 1);
|
|
|
|
}
|
|
|
|
if (e.key === " " && !alertsOpen) {
|
|
|
|
if (gameState.running) {
|
|
|
|
pause(true);
|
2025-03-01 14:20:27 +01:00
|
|
|
} else {
|
2025-03-16 17:45:29 +01:00
|
|
|
play();
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-27 18:56:04 +01:00
|
|
|
|
2025-03-14 15:49:04 +01:00
|
|
|
document.addEventListener("keyup", async (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
const focused = document.querySelector("button:focus");
|
|
|
|
if (e.key in pressed) {
|
|
|
|
setKeyPressed(e.key, 0);
|
|
|
|
} else if (
|
|
|
|
e.key === "ArrowDown" &&
|
|
|
|
focused?.nextElementSibling?.tagName === "BUTTON"
|
|
|
|
) {
|
|
|
|
(focused?.nextElementSibling as HTMLButtonElement)?.focus();
|
|
|
|
} else if (
|
|
|
|
e.key === "ArrowUp" &&
|
|
|
|
focused?.previousElementSibling?.tagName === "BUTTON"
|
|
|
|
) {
|
|
|
|
(focused?.previousElementSibling as HTMLButtonElement)?.focus();
|
|
|
|
} else if (e.key === "Escape" && closeModal) {
|
|
|
|
closeModal();
|
|
|
|
} else if (e.key === "Escape" && gameState.running) {
|
|
|
|
pause(true);
|
|
|
|
} else if (e.key.toLowerCase() === "m" && !alertsOpen) {
|
|
|
|
openSettingsPanel().then();
|
|
|
|
} else if (e.key.toLowerCase() === "s" && !alertsOpen) {
|
|
|
|
openScorePanel().then();
|
|
|
|
} else if (e.key.toLowerCase() === "r" && !alertsOpen) {
|
|
|
|
if (await confirmRestart()) {
|
|
|
|
restart({ levelToAvoid: currentLevelInfo(gameState).name });
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-23 21:17:22 +01:00
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
export const gameState = newGameState({});
|
2025-03-14 11:59:49 +01:00
|
|
|
|
2025-03-14 12:23:19 +01:00
|
|
|
export function restart(params: RunParams) {
|
2025-03-16 17:45:29 +01:00
|
|
|
Object.assign(gameState, newGameState(params));
|
|
|
|
pauseRecording();
|
|
|
|
setLevel(gameState, 0);
|
2025-03-14 12:23:19 +01:00
|
|
|
}
|
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
restart({});
|
2025-03-06 16:46:25 +01:00
|
|
|
fitSize();
|
|
|
|
tick();
|
2025-03-15 21:29:38 +01:00
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
// window.stressTest= ()=>restart({level:'Shark',perks:{base_combo:100, pierce:10, multiball:8}})
|
2025-03-16 17:45:29 +01:00
|
|
|
window.stressTest = () =>
|
|
|
|
restart({ level: "Shark", perks: { sapper: 2, pierce: 10, multiball: 3 } });
|