breakout71/src/game.ts

977 lines
26 KiB
TypeScript
Raw Normal View History

2025-03-29 21:28:05 +01:00
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
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";
import {
2025-03-29 21:28:05 +01:00
cycleMaxCoins,
cycleMaxParticles,
getCurrentMaxCoins,
getCurrentMaxParticles,
getSettingValue,
getTotalScore,
setSettingValue,
} 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,
} from "./gameStateMutators";
2025-03-29 21:28:05 +01:00
import {
backgroundCanvas,
ctx,
gameCanvas,
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";
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-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;
// 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;
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`,
);
};
window.addEventListener("resize", fitSize);
2025-03-01 21:59:41 +01:00
window.addEventListener("fullscreenchange", fitSize);
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 = "";
if (gameState.levelWallBounces < 3) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
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++;
timeGain = t("level_up.plus_one_upgrade_and_reroll");
2025-03-29 21:28:05 +01:00
} else if (gameState.levelTime < 60 * 1000) {
repeats++;
timeGain = t("level_up.plus_one_upgrade");
2025-03-29 21:28:05 +01:00
}
if (catchRate > 0.95) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
catchGain = t("level_up.plus_one_upgrade_and_reroll");
2025-03-29 21:28:05 +01:00
} else if (catchRate > 0.9) {
repeats++;
catchGain = t("level_up.plus_one_upgrade");
2025-03-29 21:28:05 +01:00
}
if (gameState.levelMisses < 3) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
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>
<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,
pickedUpgradesHTMl(gameState),
`<div id="level-recording-container"></div>`,
2025-03-29 21:28:05 +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-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-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-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-03-07 11:34:11 +01:00
gameCanvas.addEventListener("touchend", (e) => {
2025-03-29 21:28:05 +01:00
e.preventDefault();
pause(true);
});
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-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);
});
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),
);
}
export function hasBrick(index: number): number | undefined {
2025-03-29 21:28:05 +01:00
if (gameState.bricks[index]) return index;
}
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
}
export function tick() {
2025-03-29 21:28:05 +01:00
const currentTick = performance.now();
const timeDeltaMs = currentTick - gameState.lastTick;
gameState.lastTick = currentTick;
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-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);
window.addEventListener("visibilitychange", () => {
2025-03-29 21:28:05 +01:00
if (document.hidden) {
pause(true);
}
});
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();
}
});
document.addEventListener("visibilitychange", () => {
2025-03-29 21:28:05 +01:00
if (document.hidden) {
pause(true);
}
});
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-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-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
...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;
}
}
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");
},
},
];
}
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-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-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 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 21:28:05 +01:00
} catch (e) {
console.warn(e);
}
return false;
}
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-03-29 21:28:05 +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;
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-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;
}
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);
} else {
2025-03-29 21:28:05 +01:00
play();
}
2025-03-29 21:28:05 +01:00
} else {
return;
}
e.preventDefault();
});
let pageLoad = new Date();
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();
} else if (
e.key.toLowerCase() === "r" &&
!alertsOpen &&
2025-04-02 10:41:35 +02:00
pageLoad < Date.now() - 500
) {
// 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-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
tick();
2025-04-01 13:39:09 +02:00
setupTooltips();
document
.getElementById("menu")
?.setAttribute("data-tooltip", t("play.menu_tooltip"));