breakout71/src/game.ts

1025 lines
27 KiB
TypeScript
Raw Normal View History

2025-03-16 17:45:29 +01:00
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
import {
2025-03-16 17:45:29 +01:00
Ball,
Coin,
GameState,
2025-03-18 14:16:12 +01:00
LightFlash,
2025-03-16 17:45:29 +01:00
OptionId,
2025-03-18 14:16:12 +01:00
ParticleFlash,
2025-03-16 17:45:29 +01:00
PerkId,
RunParams,
2025-03-18 14:16:12 +01:00
TextFlash,
2025-03-16 17:45:29 +01:00
Upgrade,
} from "./types";
2025-03-18 14:16:12 +01:00
import { getAudioContext, playPendingSounds } from "./sounds";
2025-03-16 17:45:29 +01:00
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 {
2025-03-18 14:16:12 +01:00
empty,
forEachLiveOne,
2025-03-16 17:45:29 +01:00
gameStateTick,
normalizeGameState,
pickRandomUpgrades,
putBallsAtPuck,
resetBalls,
resetCombo,
setLevel,
setMousePos,
} 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-18 14:16:12 +01:00
import { hashCode } from "./getLevelBackground";
2025-03-05 22:10:17 +01:00
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-18 14:16:12 +01:00
gameState.ballStickToPuck = false;
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-07 11:34:11 +01:00
export const fitSize = () => {
2025-03-18 14:16:12 +01:00
const past_off = gameState.offsetXRoundedDown,
past_width = gameState.gameZoneWidthRoundedUp,
past_heigh = gameState.gameZoneHeight;
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);
2025-03-18 14:16:12 +01:00
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);
2025-03-16 17:45:29 +01:00
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-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>
<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-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++;
}
}
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();
2025-03-17 11:50:13 +01:00
if (isOptionOn("pointerLock") && gameCanvas.requestPointerLock) {
gameCanvas.requestPointerLock().then();
2025-02-16 21:21:12 +01:00
}
2025-03-16 17:45:29 +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-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;
2025-03-18 14:16:12 +01:00
2025-03-16 17:45:29 +01:00
setMousePos(gameState, e.touches[0].pageX);
2025-03-18 14:16:12 +01:00
normalizeGameState(gameState);
2025-03-16 17:45:29 +01:00
play();
});
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-03-07 11:34:11 +01:00
gameCanvas.addEventListener("touchcancel", (e) => {
2025-03-16 17:45:29 +01:00
e.preventDefault();
pause(true);
});
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);
});
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),
);
}
export function hasBrick(index: number): number | undefined {
2025-03-16 17:45:29 +01:00
if (gameState.bricks[index]) return index;
}
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
}
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;
}
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 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
}
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
}
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-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-18 14:16:12 +01:00
if (isOptionOn("sound")) {
playPendingSounds(gameState);
}
2025-03-16 17:45:29 +01:00
requestAnimationFrame(tick);
}
window.addEventListener("visibilitychange", () => {
2025-03-16 17:45:29 +01:00
if (document.hidden) {
pause(true);
}
});
2025-03-07 11:34:11 +01:00
scoreDisplay.addEventListener("click", (e) => {
2025-03-16 17:45:29 +01:00
e.preventDefault();
openScorePanel();
});
document.addEventListener("visibilitychange", () => {
2025-03-16 17:45:29 +01:00
if (document.hidden) {
pause(true);
}
});
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>
<p>${pickedUpgradesHTMl(gameState)}</p>
`,
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-03-18 14:16:12 +01:00
(document.getElementById("menu") as HTMLButtonElement).addEventListener(
"click",
(e) => {
e.preventDefault();
openSettingsPanel();
},
);
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({
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();
}
},
});
2025-03-17 11:50:13 +01:00
actions.push({
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;
}
2025-03-18 14:16:12 +01:00
const signedPayload = JSON.stringify(localStorageContent);
2025-03-17 11:50:13 +01:00
const dlLink = document.createElement("a");
dlLink.setAttribute(
"href",
"data:application/json;base64," +
btoa(
JSON.stringify({
fileType: "B71-save-file",
appVersion,
2025-03-17 19:02:19 +01:00
signedPayload,
2025-03-18 14:16:12 +01:00
key: hashCode(
"Security by obscurity, but really the game is oss so eh" +
signedPayload,
),
2025-03-17 11:50:13 +01:00
}),
),
);
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({
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");
2025-03-18 15:49:11 +01:00
input.setAttribute("accept", ".b71,.json");
2025-03-17 11:50:13 +01:00
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,
2025-03-18 14:16:12 +01:00
signedPayload,
key,
2025-03-17 11:50:13 +01:00
} = 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.",
);
2025-03-17 19:02:19 +01:00
2025-03-18 14:16:12 +01:00
if (
key !==
hashCode(
"Security by obscurity, but really the game is oss so eh" +
signedPayload,
)
) {
throw new Error("Key does not match content.");
2025-03-17 19:02:19 +01:00
}
2025-03-18 14:16:12 +01:00
const localStorageContent = JSON.parse(signedPayload);
2025-03-17 11:50:13 +01:00
localStorage.clear();
for (let key in localStorageContent) {
localStorage.setItem(key, localStorageContent[key]);
}
await asyncAlert({
title: t("main_menu.save_file_loaded"),
text: t("main_menu.save_file_loaded_help"),
actions: [{ text: t("main_menu.save_file_loaded_ok") }],
});
window.location.reload();
}
} catch (e: any) {
await asyncAlert({
title: t("main_menu.save_file_error"),
text: e.message,
actions: [{ text: t("main_menu.save_file_loaded_ok") }],
});
}
input.value = "";
});
document.body.appendChild(input);
}
document.getElementById("save_file_picker")?.click();
},
});
2025-03-16 17:45:29 +01:00
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
}
}
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-16 17:45:29 +01:00
textAfterButtons: `<p>
2025-03-14 11:59:49 +01:00
Your high score is ${gameState.highScore}.
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-03-16 17:45:29 +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-16 17:45:29 +01:00
} catch (e) {
console.warn(e);
}
}
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-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;
}
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);
} else {
2025-03-16 17:45:29 +01:00
play();
}
2025-03-16 17:45:29 +01:00
} else {
return;
}
e.preventDefault();
});
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();
});
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({});
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 = () =>
2025-03-18 14:16:12 +01:00
restart({ level: "Bird", perks: { sapper: 2, pierce: 10, multiball: 3 } });