2025-03-29 21:28:05 +01:00
|
|
|
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
|
2025-03-16 14:29:14 +01:00
|
|
|
import {
|
2025-03-29 21:28:05 +01:00
|
|
|
Ball,
|
|
|
|
Coin,
|
|
|
|
GameState,
|
|
|
|
LightFlash,
|
|
|
|
OptionId,
|
|
|
|
ParticleFlash,
|
|
|
|
PerkId,
|
|
|
|
RunParams,
|
|
|
|
TextFlash,
|
|
|
|
Upgrade,
|
2025-03-16 17:45:29 +01:00
|
|
|
} from "./types";
|
2025-03-29 21:28:05 +01:00
|
|
|
import { getAudioContext, playPendingSounds } from "./sounds";
|
|
|
|
import {
|
2025-04-01 13:39:09 +02:00
|
|
|
currentLevelInfo,
|
|
|
|
describeLevel,
|
|
|
|
getRowColIndex,
|
2025-04-06 10:13:10 +02:00
|
|
|
highScoreText,
|
2025-03-29 21:28:05 +01:00
|
|
|
levelsListHTMl,
|
|
|
|
max_levels,
|
|
|
|
pickedUpgradesHTMl,
|
|
|
|
} from "./game_utils";
|
2025-03-16 17:45:29 +01:00
|
|
|
|
|
|
|
import "./PWA/sw_loader";
|
2025-03-29 21:28:05 +01:00
|
|
|
import { getCurrentLang, t } from "./i18n/i18n";
|
2025-03-23 15:48:21 +01:00
|
|
|
import {
|
2025-03-29 21:28:05 +01:00
|
|
|
cycleMaxCoins,
|
|
|
|
cycleMaxParticles,
|
|
|
|
getCurrentMaxCoins,
|
|
|
|
getCurrentMaxParticles,
|
|
|
|
getSettingValue,
|
|
|
|
getTotalScore,
|
|
|
|
setSettingValue,
|
2025-03-23 15:48:21 +01:00
|
|
|
} from "./settings";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
2025-03-29 21:28:05 +01:00
|
|
|
forEachLiveOne,
|
|
|
|
gameStateTick,
|
|
|
|
normalizeGameState,
|
|
|
|
pickRandomUpgrades,
|
|
|
|
setLevel,
|
|
|
|
setMousePos,
|
2025-03-16 14:29:14 +01:00
|
|
|
} from "./gameStateMutators";
|
2025-03-29 21:28:05 +01:00
|
|
|
import {
|
|
|
|
backgroundCanvas,
|
|
|
|
ctx,
|
|
|
|
gameCanvas,
|
2025-04-03 15:15:00 +02:00
|
|
|
haloCanvas,
|
|
|
|
haloScale,
|
2025-03-29 21:28:05 +01:00
|
|
|
render,
|
|
|
|
scoreDisplay,
|
|
|
|
} from "./render";
|
|
|
|
import {
|
|
|
|
pauseRecording,
|
|
|
|
recordOneFrame,
|
|
|
|
resumeRecording,
|
|
|
|
startRecordingGame,
|
|
|
|
} from "./recording";
|
|
|
|
import { newGameState } from "./newGameState";
|
|
|
|
import {
|
|
|
|
alertsOpen,
|
|
|
|
asyncAlert,
|
|
|
|
AsyncAlertAction,
|
|
|
|
closeModal,
|
|
|
|
requiredAsyncAlert,
|
|
|
|
} from "./asyncAlert";
|
|
|
|
import { isOptionOn, options, toggleOption } from "./options";
|
|
|
|
import { hashCode } from "./getLevelBackground";
|
2025-03-30 21:07:58 +02:00
|
|
|
import { hoursSpentPlaying } from "./pure_functions";
|
2025-03-31 20:13:47 +02:00
|
|
|
import { helpMenuEntry } from "./help";
|
2025-04-01 13:35:33 +02:00
|
|
|
import { creativeMode } from "./creative";
|
2025-04-01 13:39:09 +02:00
|
|
|
import { setupTooltips } from "./tooltip";
|
2025-04-02 10:42:01 +02:00
|
|
|
import { startingPerkMenuButton } from "./startingPerks";
|
2025-04-02 17:03:53 +02:00
|
|
|
import "./migrations";
|
2025-04-06 10:13:10 +02:00
|
|
|
import {getCreativeModeWarning} from "./gameOver";
|
2025-03-05 22:10:17 +01:00
|
|
|
|
2025-04-02 10:41:35 +02:00
|
|
|
export async function play() {
|
|
|
|
if (await applyFullScreenChoice()) return;
|
2025-03-29 21:28:05 +01:00
|
|
|
if (gameState.running) return;
|
|
|
|
gameState.running = true;
|
|
|
|
gameState.ballStickToPuck = false;
|
|
|
|
|
|
|
|
startRecordingGame(gameState);
|
|
|
|
getAudioContext()?.resume();
|
|
|
|
resumeRecording();
|
|
|
|
// document.body.classList[gameState.running ? 'add' : 'remove']('running')
|
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-29 21:28:05 +01:00
|
|
|
if (!gameState.running) return;
|
|
|
|
if (gameState.pauseTimeout) return;
|
2025-03-16 17:45:29 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
const stop = () => {
|
|
|
|
gameState.running = false;
|
2025-03-16 17:45:29 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
setTimeout(() => {
|
|
|
|
if (!gameState.running) getAudioContext()?.suspend();
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
pauseRecording();
|
|
|
|
gameState.pauseTimeout = null;
|
|
|
|
// document.body.className = gameState.running ? " running " : " paused ";
|
|
|
|
scoreDisplay.className = "";
|
|
|
|
gameState.needsRender = true;
|
|
|
|
};
|
|
|
|
|
|
|
|
if (playerAskedForPause) {
|
|
|
|
// Pausing many times in a run will make pause slower
|
|
|
|
gameState.pauseUsesDuringRun++;
|
|
|
|
gameState.pauseTimeout = setTimeout(
|
|
|
|
stop,
|
|
|
|
Math.min(Math.max(0, gameState.pauseUsesDuringRun - 5) * 50, 500),
|
2025-03-23 16:11:12 +01:00
|
|
|
);
|
2025-03-29 21:28:05 +01:00
|
|
|
} else {
|
|
|
|
stop();
|
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
if (document.exitPointerLock) {
|
|
|
|
document.exitPointerLock();
|
|
|
|
}
|
|
|
|
}
|
2025-03-12 15:15:30 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
export const fitSize = () => {
|
|
|
|
const past_off = gameState.offsetXRoundedDown,
|
|
|
|
past_width = gameState.gameZoneWidthRoundedUp,
|
|
|
|
past_heigh = gameState.gameZoneHeight;
|
|
|
|
|
|
|
|
const { width, height } = gameCanvas.getBoundingClientRect();
|
|
|
|
gameState.canvasWidth = width;
|
|
|
|
gameState.canvasHeight = height;
|
|
|
|
gameCanvas.width = width;
|
|
|
|
gameCanvas.height = height;
|
2025-04-03 15:15:00 +02:00
|
|
|
// ctx.fillStyle = currentLevelInfo(gameState)?.color || "black";
|
|
|
|
// ctx.globalAlpha = 1;
|
|
|
|
// ctx.fillRect(0, 0, width, height);
|
2025-03-29 21:28:05 +01:00
|
|
|
backgroundCanvas.width = width;
|
|
|
|
backgroundCanvas.height = height;
|
|
|
|
|
2025-04-03 15:15:00 +02:00
|
|
|
haloCanvas.width = width / haloScale;
|
|
|
|
haloCanvas.height = height / haloScale;
|
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
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);
|
|
|
|
|
|
|
|
function mapXY(item: ParticleFlash | TextFlash | LightFlash) {
|
|
|
|
item.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
((item.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
|
|
|
|
item.y = (item.y / past_heigh) * gameState.gameZoneHeight;
|
|
|
|
}
|
|
|
|
|
|
|
|
function mapXYPastCoord(coin: Coin | Ball) {
|
|
|
|
coin.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
((coin.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
|
|
|
|
coin.y = (coin.y / past_heigh) * gameState.gameZoneHeight;
|
|
|
|
coin.previousX = coin.x;
|
|
|
|
coin.previousY = coin.y;
|
|
|
|
}
|
|
|
|
|
|
|
|
gameState.balls.forEach(mapXYPastCoord);
|
|
|
|
forEachLiveOne(gameState.coins, mapXYPastCoord);
|
|
|
|
forEachLiveOne(gameState.particles, mapXY);
|
|
|
|
forEachLiveOne(gameState.texts, mapXY);
|
|
|
|
forEachLiveOne(gameState.lights, mapXY);
|
|
|
|
pause(true);
|
|
|
|
// 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-29 21:28:05 +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-29 20:45:54 +01:00
|
|
|
export async function openUpgradesPicker(gameState: GameState) {
|
2025-03-29 21:28:05 +01:00
|
|
|
const catchRate =
|
|
|
|
(gameState.score - gameState.levelStartScore) /
|
|
|
|
(gameState.levelSpawnedCoins || 1);
|
|
|
|
|
|
|
|
let repeats = 1;
|
|
|
|
|
|
|
|
let timeGain = "",
|
|
|
|
catchGain = "",
|
|
|
|
wallHitsGain = "",
|
|
|
|
missesGain = "";
|
|
|
|
|
2025-03-30 21:07:58 +02:00
|
|
|
if (gameState.levelWallBounces < 3) {
|
2025-03-29 21:28:05 +01:00
|
|
|
repeats++;
|
|
|
|
gameState.rerolls++;
|
2025-03-30 21:07:58 +02:00
|
|
|
wallHitsGain = t("level_up.plus_one_upgrade_and_reroll");
|
|
|
|
} else if (gameState.levelWallBounces < 10) {
|
|
|
|
repeats++;
|
2025-03-29 21:28:05 +01:00
|
|
|
wallHitsGain = t("level_up.plus_one_upgrade");
|
|
|
|
}
|
|
|
|
if (gameState.levelTime < 30 * 1000) {
|
|
|
|
repeats++;
|
|
|
|
gameState.rerolls++;
|
2025-03-30 21:07:58 +02:00
|
|
|
timeGain = t("level_up.plus_one_upgrade_and_reroll");
|
2025-03-29 21:28:05 +01:00
|
|
|
} else if (gameState.levelTime < 60 * 1000) {
|
2025-03-30 21:07:58 +02:00
|
|
|
repeats++;
|
|
|
|
timeGain = t("level_up.plus_one_upgrade");
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-03-30 21:07:58 +02:00
|
|
|
if (catchRate > 0.95) {
|
2025-03-29 21:28:05 +01:00
|
|
|
repeats++;
|
|
|
|
gameState.rerolls++;
|
2025-03-30 21:07:58 +02:00
|
|
|
catchGain = t("level_up.plus_one_upgrade_and_reroll");
|
2025-03-29 21:28:05 +01:00
|
|
|
} else if (catchRate > 0.9) {
|
2025-03-30 21:07:58 +02:00
|
|
|
repeats++;
|
|
|
|
catchGain = t("level_up.plus_one_upgrade");
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-03-30 21:07:58 +02:00
|
|
|
if (gameState.levelMisses < 3) {
|
2025-03-29 21:28:05 +01:00
|
|
|
repeats++;
|
|
|
|
gameState.rerolls++;
|
2025-03-30 21:07:58 +02:00
|
|
|
missesGain = t("level_up.plus_one_upgrade_and_reroll");
|
|
|
|
} else if (gameState.levelMisses < 6) {
|
|
|
|
repeats++;
|
2025-03-29 21:28:05 +01:00
|
|
|
missesGain = t("level_up.plus_one_upgrade");
|
|
|
|
}
|
|
|
|
|
|
|
|
while (repeats--) {
|
|
|
|
const actions: Array<{
|
|
|
|
text: string;
|
|
|
|
icon: string;
|
|
|
|
value: PerkId | "reroll";
|
|
|
|
help: string;
|
|
|
|
}> = pickRandomUpgrades(
|
|
|
|
gameState,
|
|
|
|
3 + gameState.perks.one_more_choice - gameState.perks.instant_upgrade,
|
|
|
|
);
|
|
|
|
if (!actions.length) break;
|
2025-03-29 20:45:54 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
if (gameState.rerolls)
|
|
|
|
actions.push({
|
|
|
|
text: t("level_up.reroll", { count: gameState.rerolls }),
|
|
|
|
help: t("level_up.reroll_help"),
|
|
|
|
value: "reroll" as const,
|
|
|
|
icon: icons["icon:reroll"],
|
|
|
|
});
|
2025-03-27 10:52:31 +01:00
|
|
|
|
2025-03-29 21:28:05 +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 requiredAsyncAlert<PerkId | "reroll">({
|
|
|
|
title:
|
|
|
|
t("level_up.pick_upgrade_title") +
|
|
|
|
(repeats ? " (" + (repeats + 1) + ")" : ""),
|
|
|
|
content: [
|
|
|
|
`<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-23 19:11:01 +01:00
|
|
|
</p>
|
2025-03-30 21:07:58 +02:00
|
|
|
<p>${t("level_up.after_buttons", {
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
|
|
|
})} </p>
|
2025-04-04 09:45:35 +02:00
|
|
|
<p>${levelsListHTMl(gameState, gameState.currentLevel + 1)}</p>
|
2025-03-23 19:11:01 +01:00
|
|
|
`,
|
2025-03-29 21:28:05 +01:00
|
|
|
...actions,
|
2025-03-30 21:07:58 +02:00
|
|
|
pickedUpgradesHTMl(gameState),
|
|
|
|
|
|
|
|
`<div id="level-recording-container"></div>`,
|
2025-03-29 21:28:05 +01:00
|
|
|
],
|
|
|
|
});
|
2025-03-15 10:34:01 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
if (upgradeId === "reroll") {
|
|
|
|
repeats++;
|
|
|
|
gameState.rerolls--;
|
|
|
|
} else {
|
|
|
|
gameState.perks[upgradeId]++;
|
|
|
|
if (upgradeId === "instant_upgrade") {
|
|
|
|
repeats += 2;
|
|
|
|
}
|
|
|
|
gameState.runStatistics.upgrades_picked++;
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mouseup", (e) => {
|
2025-03-29 21:28:05 +01:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
if (gameState.running) {
|
|
|
|
pause(true);
|
|
|
|
} else {
|
|
|
|
play();
|
|
|
|
if (isOptionOn("pointerLock") && gameCanvas.requestPointerLock) {
|
|
|
|
gameCanvas.requestPointerLock().then();
|
2025-02-16 21:21:12 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mousemove", (e) => {
|
2025-03-29 21:28:05 +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-29 21:28:05 +01:00
|
|
|
e.preventDefault();
|
|
|
|
if (!e.touches?.length) return;
|
2025-03-18 14:16:12 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
setMousePos(gameState, e.touches[0].pageX);
|
|
|
|
normalizeGameState(gameState);
|
|
|
|
play();
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchend", (e) => {
|
2025-03-29 21:28:05 +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-29 21:28:05 +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-29 21:28:05 +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-29 21:28:05 +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-29 21:28:05 +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-29 21:28:05 +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 tick() {
|
2025-03-29 21:28:05 +01:00
|
|
|
const currentTick = performance.now();
|
|
|
|
const timeDeltaMs = currentTick - gameState.lastTick;
|
|
|
|
gameState.lastTick = currentTick;
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
const frames = Math.min(4, timeDeltaMs / (1000 / 60));
|
2025-03-29 15:00:44 +01:00
|
|
|
|
2025-03-29 21:28:05 +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);
|
|
|
|
}
|
|
|
|
if (gameState.running || gameState.needsRender) {
|
|
|
|
gameState.needsRender = false;
|
|
|
|
render(gameState);
|
|
|
|
}
|
|
|
|
if (gameState.running) {
|
|
|
|
recordOneFrame(gameState);
|
|
|
|
}
|
|
|
|
if (isOptionOn("sound")) {
|
|
|
|
playPendingSounds(gameState);
|
|
|
|
}
|
|
|
|
|
|
|
|
requestAnimationFrame(tick);
|
|
|
|
FPSCounter++;
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-23 16:11:12 +01:00
|
|
|
let FPSCounter = 0;
|
2025-03-29 21:05:53 +01:00
|
|
|
export let lastMeasuredFPS = 60;
|
|
|
|
|
2025-03-23 16:11:12 +01:00
|
|
|
setInterval(() => {
|
2025-03-29 21:28:05 +01:00
|
|
|
lastMeasuredFPS = FPSCounter;
|
|
|
|
FPSCounter = 0;
|
2025-03-23 16:11:12 +01:00
|
|
|
}, 1000);
|
2025-03-23 15:48:21 +01:00
|
|
|
|
2025-02-15 19:21:00 +01:00
|
|
|
window.addEventListener("visibilitychange", () => {
|
2025-03-29 21:28:05 +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-29 21:28:05 +01:00
|
|
|
e.preventDefault();
|
|
|
|
if (!alertsOpen) {
|
|
|
|
openScorePanel();
|
|
|
|
}
|
2025-02-27 18:56:04 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
document.addEventListener("visibilitychange", () => {
|
2025-03-29 21:28:05 +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-29 21:28:05 +01:00
|
|
|
pause(true);
|
|
|
|
|
|
|
|
const cb = await asyncAlert({
|
2025-04-06 10:13:10 +02:00
|
|
|
title: t("score_panel.title", {
|
2025-03-29 21:28:05 +01:00
|
|
|
score: gameState.score,
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
|
|
|
}),
|
|
|
|
|
|
|
|
content: [
|
2025-04-06 10:13:10 +02:00
|
|
|
|
|
|
|
getCreativeModeWarning(gameState),
|
2025-03-29 21:28:05 +01:00
|
|
|
pickedUpgradesHTMl(gameState),
|
2025-04-04 09:45:35 +02:00
|
|
|
levelsListHTMl(gameState, gameState.currentLevel),
|
2025-03-29 21:28:05 +01:00
|
|
|
gameState.rerolls
|
|
|
|
? t("score_panel.rerolls_count", { rerolls: gameState.rerolls })
|
|
|
|
: "",
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
});
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
(document.getElementById("menu") as HTMLButtonElement).addEventListener(
|
2025-03-29 21:28:05 +01:00
|
|
|
"click",
|
|
|
|
(e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
if (!alertsOpen) {
|
|
|
|
openMainMenu();
|
|
|
|
}
|
|
|
|
},
|
2025-03-18 14:16:12 +01:00
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-04-01 13:35:33 +02:00
|
|
|
export const creativeModeThreshold = Math.max(
|
|
|
|
...upgrades.map((u) => u.threshold),
|
|
|
|
);
|
|
|
|
|
2025-03-26 08:01:12 +01:00
|
|
|
export async function openMainMenu() {
|
2025-03-29 21:28:05 +01:00
|
|
|
pause(true);
|
|
|
|
|
|
|
|
const actions: AsyncAlertAction<() => void>[] = [
|
|
|
|
{
|
|
|
|
icon: icons["icon:7_levels_run"],
|
|
|
|
text: t("main_menu.normal"),
|
2025-04-06 10:13:10 +02:00
|
|
|
help: highScoreText() || t("main_menu.normal_help"),
|
2025-03-29 21:28:05 +01:00
|
|
|
value: () => {
|
2025-04-01 13:35:33 +02:00
|
|
|
restart({
|
2025-04-06 10:13:10 +02:00
|
|
|
levelToAvoid: currentLevelInfo(gameState).name
|
2025-04-01 13:35:33 +02:00
|
|
|
});
|
2025-03-29 21:28:05 +01:00
|
|
|
},
|
|
|
|
},
|
2025-04-01 13:35:33 +02:00
|
|
|
creativeMode(gameState),
|
2025-03-29 21:28:05 +01:00
|
|
|
{
|
2025-04-01 13:35:33 +02:00
|
|
|
icon: icons["icon:unlocks"],
|
|
|
|
text: t("main_menu.unlocks"),
|
|
|
|
help: t("main_menu.unlocks_help"),
|
|
|
|
value() {
|
|
|
|
openUnlocksList();
|
2025-03-29 21:28:05 +01:00
|
|
|
},
|
|
|
|
},
|
2025-03-29 15:00:44 +01:00
|
|
|
|
2025-03-30 21:07:58 +02:00
|
|
|
...donationNag(gameState),
|
2025-03-29 21:28:05 +01:00
|
|
|
{
|
|
|
|
text: t("main_menu.settings_title"),
|
|
|
|
help: t("main_menu.settings_help"),
|
|
|
|
icon: icons["icon:settings"],
|
|
|
|
value() {
|
|
|
|
openSettingsMenu();
|
|
|
|
},
|
|
|
|
},
|
2025-03-31 20:13:47 +02:00
|
|
|
helpMenuEntry(),
|
2025-03-29 21:28:05 +01:00
|
|
|
];
|
|
|
|
|
|
|
|
const cb = await asyncAlert<() => void>({
|
|
|
|
title: t("main_menu.title"),
|
|
|
|
content: [...actions, t("main_menu.footer_html", { appVersion })],
|
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
|
|
|
gameState.needsRender = true;
|
|
|
|
}
|
2025-03-23 15:48:21 +01:00
|
|
|
}
|
|
|
|
|
2025-03-30 21:07:58 +02:00
|
|
|
function donationNag(gameState) {
|
|
|
|
if (!isOptionOn("donation_reminder")) return [];
|
|
|
|
const hours = hoursSpentPlaying();
|
|
|
|
return [
|
|
|
|
{
|
|
|
|
text: t("main_menu.donate", { hours }),
|
|
|
|
help: t("main_menu.donate_help", {
|
|
|
|
suggestion: Math.min(20, Math.max(1, 0.2 * hours)).toFixed(0),
|
|
|
|
}),
|
|
|
|
icon: icons["icon:premium"],
|
|
|
|
value() {
|
|
|
|
window.open("https://paypal.me/renanlecaro", "_blank");
|
|
|
|
},
|
|
|
|
},
|
|
|
|
];
|
|
|
|
}
|
2025-03-23 15:48:21 +01:00
|
|
|
async function openSettingsMenu() {
|
2025-03-29 21:28:05 +01:00
|
|
|
pause(true);
|
|
|
|
|
2025-04-02 10:42:01 +02:00
|
|
|
const actions: AsyncAlertAction<() => void>[] = [startingPerkMenuButton()];
|
2025-03-29 21:28:05 +01:00
|
|
|
|
2025-04-02 10:42:01 +02:00
|
|
|
const languages = [
|
|
|
|
{
|
|
|
|
text: "English",
|
|
|
|
value: "en",
|
|
|
|
icon: icons["UK"],
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: "Français",
|
|
|
|
value: "fr",
|
|
|
|
icon: icons["France"],
|
|
|
|
},
|
|
|
|
];
|
2025-04-02 10:41:35 +02:00
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: languages.find((l) => l.value === getCurrentLang())?.icon,
|
2025-04-02 10:41:35 +02:00
|
|
|
text: t("main_menu.language"),
|
|
|
|
help: t("main_menu.language_help"),
|
|
|
|
async value() {
|
|
|
|
const pick = await asyncAlert({
|
|
|
|
title: t("main_menu.language"),
|
2025-04-02 10:42:01 +02:00
|
|
|
content: [t("main_menu.language_help"), ...languages],
|
2025-04-02 10:41:35 +02:00
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (
|
|
|
|
pick &&
|
|
|
|
pick !== getCurrentLang() &&
|
|
|
|
(await confirmRestart(gameState))
|
|
|
|
) {
|
|
|
|
setSettingValue("lang", pick);
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
2025-03-29 21:28:05 +01:00
|
|
|
for (const key of Object.keys(options) as OptionId[]) {
|
|
|
|
if (options[key])
|
|
|
|
actions.push({
|
|
|
|
icon: isOptionOn(key)
|
|
|
|
? icons["icon:checkmark_checked"]
|
|
|
|
: icons["icon:checkmark_unchecked"],
|
|
|
|
text: options[key].name,
|
|
|
|
help: options[key].help,
|
|
|
|
value: () => {
|
|
|
|
toggleOption(key);
|
|
|
|
fitSize();
|
|
|
|
applyFullScreenChoice();
|
|
|
|
openSettingsMenu();
|
2025-03-23 15:48:21 +01:00
|
|
|
},
|
2025-03-29 21:28:05 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: icons["icon:download"],
|
2025-03-29 21:28:05 +01:00
|
|
|
text: t("main_menu.download_save_file"),
|
|
|
|
help: t("main_menu.download_save_file_help"),
|
|
|
|
async value() {
|
|
|
|
const localStorageContent: Record<string, string> = {};
|
|
|
|
|
|
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
|
|
const key = localStorage.key(i) as string;
|
|
|
|
const value = localStorage.getItem(key) as string;
|
|
|
|
|
|
|
|
// Store the key-value pair in the object
|
|
|
|
localStorageContent[key] = value;
|
|
|
|
}
|
|
|
|
|
|
|
|
const signedPayload = JSON.stringify(localStorageContent);
|
|
|
|
const dlLink = document.createElement("a");
|
|
|
|
|
|
|
|
dlLink.setAttribute(
|
|
|
|
"href",
|
|
|
|
"data:application/json;base64," +
|
|
|
|
btoa(
|
|
|
|
JSON.stringify({
|
|
|
|
fileType: "B71-save-file",
|
|
|
|
appVersion,
|
|
|
|
signedPayload,
|
|
|
|
key: hashCode(
|
|
|
|
"Security by obscurity, but really the game is oss so eh" +
|
|
|
|
signedPayload,
|
|
|
|
),
|
|
|
|
}),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
dlLink.setAttribute(
|
|
|
|
"download",
|
|
|
|
"b71-save-" +
|
|
|
|
new Date()
|
|
|
|
.toISOString()
|
|
|
|
.slice(0, 19)
|
|
|
|
.replace(/[^0-9]+/gi, "-") +
|
|
|
|
".b71",
|
|
|
|
);
|
|
|
|
document.body.appendChild(dlLink);
|
|
|
|
dlLink.click();
|
|
|
|
setTimeout(() => document.body.removeChild(dlLink), 1000);
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: icons["icon:upload"],
|
2025-03-29 21:28:05 +01:00
|
|
|
text: t("main_menu.load_save_file"),
|
|
|
|
help: t("main_menu.load_save_file_help"),
|
|
|
|
async value() {
|
|
|
|
if (!document.getElementById("save_file_picker")) {
|
|
|
|
let input: HTMLInputElement = document.createElement("input");
|
|
|
|
input.setAttribute("type", "file");
|
|
|
|
input.setAttribute("id", "save_file_picker");
|
|
|
|
input.setAttribute("accept", ".b71,.json");
|
|
|
|
input.style.position = "absolute";
|
|
|
|
input.style.left = "-1000px";
|
|
|
|
input.addEventListener("change", async (e) => {
|
|
|
|
try {
|
|
|
|
const file = input && input.files?.item(0);
|
|
|
|
if (file) {
|
|
|
|
const content = await new Promise<string>((resolve, reject) => {
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = function () {
|
|
|
|
resolve(reader.result?.toString() || "");
|
|
|
|
};
|
|
|
|
reader.onerror = function () {
|
|
|
|
reject(reader.error);
|
|
|
|
};
|
|
|
|
|
|
|
|
// Read the file as a text string
|
|
|
|
|
|
|
|
reader.readAsText(file);
|
|
|
|
});
|
|
|
|
const {
|
|
|
|
fileType,
|
|
|
|
appVersion: fileVersion,
|
|
|
|
signedPayload,
|
|
|
|
key,
|
|
|
|
} = JSON.parse(content);
|
|
|
|
if (fileType !== "B71-save-file")
|
|
|
|
throw new Error("Not a B71 save file");
|
|
|
|
if (fileVersion > appVersion)
|
|
|
|
throw new Error(
|
|
|
|
"Please update your app first, this file is for version " +
|
|
|
|
fileVersion +
|
|
|
|
" or newer.",
|
|
|
|
);
|
|
|
|
|
|
|
|
if (
|
|
|
|
key !==
|
|
|
|
hashCode(
|
|
|
|
"Security by obscurity, but really the game is oss so eh" +
|
|
|
|
signedPayload,
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
throw new Error("Key does not match content.");
|
|
|
|
}
|
|
|
|
|
|
|
|
const localStorageContent = JSON.parse(signedPayload);
|
|
|
|
localStorage.clear();
|
|
|
|
for (let key in localStorageContent) {
|
|
|
|
localStorage.setItem(key, localStorageContent[key]);
|
|
|
|
}
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.save_file_loaded"),
|
2025-03-27 10:52:31 +01:00
|
|
|
content: [
|
2025-03-29 21:28:05 +01:00
|
|
|
t("main_menu.save_file_loaded_help"),
|
|
|
|
{ text: t("main_menu.save_file_loaded_ok") },
|
2025-03-27 10:52:31 +01:00
|
|
|
],
|
2025-03-29 21:28:05 +01:00
|
|
|
});
|
|
|
|
window.location.reload();
|
2025-03-29 15:00:44 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
} catch (e: any) {
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.save_file_error"),
|
|
|
|
content: [
|
|
|
|
e.message,
|
|
|
|
{ text: t("main_menu.save_file_loaded_ok") },
|
|
|
|
],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
input.value = "";
|
|
|
|
});
|
|
|
|
document.body.appendChild(input);
|
|
|
|
}
|
|
|
|
document.getElementById("save_file_picker")?.click();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: icons["icon:coins"],
|
2025-03-29 21:28:05 +01:00
|
|
|
text: t("main_menu.max_coins", { max: getCurrentMaxCoins() }),
|
|
|
|
help: t("main_menu.max_coins_help"),
|
|
|
|
async value() {
|
|
|
|
cycleMaxCoins();
|
|
|
|
await openSettingsMenu();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: icons["icon:particles"],
|
2025-03-29 21:28:05 +01:00
|
|
|
text: t("main_menu.max_particles", { max: getCurrentMaxParticles() }),
|
|
|
|
help: t("main_menu.max_particles_help"),
|
|
|
|
async value() {
|
|
|
|
cycleMaxParticles();
|
|
|
|
await openSettingsMenu();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-04-02 10:41:35 +02:00
|
|
|
actions.push({
|
2025-04-02 10:42:01 +02:00
|
|
|
icon: icons["icon:reset"],
|
2025-04-02 10:41:35 +02:00
|
|
|
text: t("main_menu.reset"),
|
|
|
|
help: t("main_menu.reset_help"),
|
|
|
|
async value() {
|
|
|
|
if (
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.reset"),
|
|
|
|
content: [
|
|
|
|
t("main_menu.reset_instruction"),
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_confirm"),
|
|
|
|
value: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_cancel"),
|
|
|
|
value: false,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
})
|
|
|
|
) {
|
|
|
|
localStorage.clear();
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
const cb = await asyncAlert<() => void>({
|
|
|
|
title: t("main_menu.settings_title"),
|
|
|
|
content: [t("main_menu.settings_help"), ...actions],
|
|
|
|
allowClose: true,
|
2025-04-04 12:07:51 +02:00
|
|
|
className: "settings",
|
2025-03-29 21:28:05 +01:00
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
|
|
|
gameState.needsRender = true;
|
|
|
|
}
|
2025-03-06 16:46:25 +01:00
|
|
|
}
|
|
|
|
|
2025-04-02 10:41:35 +02:00
|
|
|
async function applyFullScreenChoice() {
|
2025-03-29 21:28:05 +01:00
|
|
|
try {
|
|
|
|
if (!(document.fullscreenEnabled || document.webkitFullscreenEnabled)) {
|
|
|
|
return false;
|
|
|
|
}
|
2025-03-29 17:40:07 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
if (document.fullscreenElement !== null && !isOptionOn("fullscreen")) {
|
|
|
|
if (document.exitFullscreen) {
|
2025-04-02 10:41:35 +02:00
|
|
|
await document.exitFullscreen();
|
2025-03-29 21:28:05 +01:00
|
|
|
return true;
|
|
|
|
} else if (document.webkitCancelFullScreen) {
|
2025-04-02 10:42:01 +02:00
|
|
|
await document.webkitCancelFullScreen();
|
2025-03-29 21:28:05 +01:00
|
|
|
return true;
|
|
|
|
}
|
|
|
|
} else if (isOptionOn("fullscreen") && !document.fullscreenElement) {
|
|
|
|
const docel = document.documentElement;
|
|
|
|
if (docel.requestFullscreen) {
|
2025-04-02 10:42:01 +02:00
|
|
|
await docel.requestFullscreen();
|
2025-03-29 21:28:05 +01:00
|
|
|
return true;
|
|
|
|
} else if (docel.webkitRequestFullscreen) {
|
2025-04-02 10:41:35 +02:00
|
|
|
await docel.webkitRequestFullscreen();
|
2025-03-29 21:28:05 +01:00
|
|
|
return true;
|
|
|
|
}
|
2025-03-29 17:40:07 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
} catch (e) {
|
|
|
|
console.warn(e);
|
|
|
|
}
|
|
|
|
return false;
|
2025-03-29 17:40:07 +01:00
|
|
|
}
|
|
|
|
|
2025-03-06 16:46:25 +01:00
|
|
|
async function openUnlocksList() {
|
2025-03-29 21:28:05 +01:00
|
|
|
const ts = getTotalScore();
|
|
|
|
const upgradeActions = upgrades
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map(({ name, id, threshold, icon, help }) => ({
|
|
|
|
text: name,
|
|
|
|
disabled: ts < threshold,
|
|
|
|
value: { perks: { [id]: 1 } } as RunParams,
|
|
|
|
icon,
|
2025-04-01 13:39:09 +02:00
|
|
|
tooltip: help(1),
|
2025-03-29 21:28:05 +01:00
|
|
|
}));
|
|
|
|
|
|
|
|
const levelActions = allLevels
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map((l) => {
|
|
|
|
const available = ts >= l.threshold;
|
|
|
|
return {
|
|
|
|
text: l.name,
|
|
|
|
disabled: !available,
|
|
|
|
value: { level: l.name } as RunParams,
|
|
|
|
icon: icons[l.name],
|
2025-04-01 13:39:09 +02:00
|
|
|
tooltip: describeLevel(l),
|
2025-03-29 21:28:05 +01:00
|
|
|
};
|
|
|
|
});
|
2025-03-29 15:00:44 +01:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
const percentUnlock = Math.round(
|
|
|
|
([...upgradeActions, ...levelActions].filter((a) => !a.disabled).length /
|
|
|
|
(upgradeActions.length + levelActions.length)) *
|
|
|
|
100,
|
|
|
|
);
|
|
|
|
const tryOn = await asyncAlert<RunParams>({
|
|
|
|
title: t("unlocks.title", { percentUnlock }),
|
|
|
|
content: [
|
2025-04-01 13:35:33 +02:00
|
|
|
`<p>${t("unlocks.intro", { ts })}
|
2025-03-27 10:52:31 +01:00
|
|
|
${percentUnlock < 100 ? t("unlocks.greyed_out_help") : ""}</p> `,
|
2025-03-29 21:28:05 +01:00
|
|
|
...upgradeActions,
|
|
|
|
t("unlocks.level"),
|
|
|
|
...levelActions,
|
|
|
|
],
|
2025-04-04 12:07:51 +02:00
|
|
|
allowClose: true,
|
|
|
|
className: "actionsAsGrid",
|
2025-03-29 21:28:05 +01:00
|
|
|
});
|
|
|
|
if (tryOn) {
|
|
|
|
if (await confirmRestart(gameState)) {
|
2025-04-06 10:13:10 +02:00
|
|
|
restart({ ...tryOn });
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-26 08:01:12 +01:00
|
|
|
export async function confirmRestart(gameState) {
|
2025-04-06 10:13:10 +02:00
|
|
|
|
2025-03-29 21:28:05 +01:00
|
|
|
if (!gameState.currentLevel) return true;
|
2025-03-30 21:07:58 +02:00
|
|
|
if (alertsOpen) return true;
|
2025-04-06 10:13:10 +02:00
|
|
|
pause(true)
|
2025-03-29 21:28:05 +01:00
|
|
|
return asyncAlert({
|
|
|
|
title: t("confirmRestart.title"),
|
|
|
|
content: [
|
|
|
|
t("confirmRestart.text"),
|
|
|
|
{
|
|
|
|
value: true,
|
|
|
|
text: t("confirmRestart.yes"),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
value: false,
|
|
|
|
text: t("confirmRestart.no"),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
2025-03-16 10:24:46 +01:00
|
|
|
}
|
|
|
|
|
2025-03-11 13:56:42 +01:00
|
|
|
const pressed: { [k: string]: number } = {
|
2025-03-29 21:28:05 +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-29 21:28:05 +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-30 21:07:58 +02:00
|
|
|
document.addEventListener("keydown", async (e) => {
|
2025-03-29 21:28:05 +01:00
|
|
|
if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) {
|
|
|
|
toggleOption("fullscreen");
|
|
|
|
applyFullScreenChoice();
|
|
|
|
} 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-29 21:28:05 +01:00
|
|
|
play();
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-27 18:56:04 +01:00
|
|
|
|
2025-03-30 21:07:58 +02:00
|
|
|
let pageLoad = new Date();
|
2025-03-14 15:49:04 +01:00
|
|
|
document.addEventListener("keyup", async (e) => {
|
2025-03-29 21:28:05 +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) {
|
|
|
|
openMainMenu().then();
|
|
|
|
} else if (e.key.toLowerCase() === "s" && !alertsOpen) {
|
|
|
|
openScorePanel().then();
|
2025-03-30 21:07:58 +02:00
|
|
|
} else if (
|
|
|
|
e.key.toLowerCase() === "r" &&
|
|
|
|
!alertsOpen &&
|
2025-04-02 10:41:35 +02:00
|
|
|
pageLoad < Date.now() - 500
|
2025-03-30 21:07:58 +02:00
|
|
|
) {
|
|
|
|
// When doing ctrl + R in dev to refresh, i don't want to instantly restart a run
|
2025-03-29 21:28:05 +01:00
|
|
|
if (await confirmRestart(gameState)) {
|
2025-04-01 13:35:33 +02:00
|
|
|
restart({
|
2025-04-06 10:13:10 +02:00
|
|
|
levelToAvoid: currentLevelInfo(gameState).name
|
2025-04-01 13:35:33 +02:00
|
|
|
});
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-29 21:28:05 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-23 21:17:22 +01:00
|
|
|
|
2025-04-06 10:13:10 +02: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-29 21:28:05 +01:00
|
|
|
fitSize();
|
|
|
|
Object.assign(gameState, newGameState(params));
|
|
|
|
pauseRecording();
|
|
|
|
setLevel(gameState, 0);
|
2025-03-14 12:23:19 +01:00
|
|
|
}
|
|
|
|
|
2025-04-06 10:13:10 +02:00
|
|
|
restart({ });
|
2025-03-26 14:04:54 +01:00
|
|
|
|
2025-03-06 16:46:25 +01:00
|
|
|
tick();
|
2025-04-01 13:39:09 +02:00
|
|
|
setupTooltips();
|
|
|
|
document
|
|
|
|
.getElementById("menu")
|
|
|
|
?.setAttribute("data-tooltip", t("play.menu_tooltip"));
|