breakout71/src/game.ts

1102 lines
30 KiB
TypeScript
Raw Normal View History

2025-04-14 13:39:30 +02:00
import {
allLevels,
allLevelsAndIcons,
appVersion,
icons,
upgrades,
} from "./loadGameData";
2025-04-07 14:08:48 +02:00
import {
Ball,
Coin,
GameState,
LightFlash,
OptionId,
ParticleFlash,
PerkId,
2025-04-12 20:01:43 +02:00
PerksMap,
2025-04-07 14:08:48 +02:00
RunParams,
TextFlash,
} from "./types";
import { getAudioContext, playPendingSounds } from "./sounds";
2025-03-29 21:28:05 +01:00
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,
2025-04-06 18:21:53 +02:00
reasonLevelIsLocked,
2025-04-15 21:28:00 +02:00
sample,
sumOfValues,
2025-03-29 21:28:05 +01:00
} from "./game_utils";
2025-03-16 17:45:29 +01:00
import "./PWA/sw_loader";
2025-04-09 11:28:32 +02:00
import { getCurrentLang, languages, t } from "./i18n/i18n";
import {
2025-04-16 09:26:10 +02:00
commitSettingsChangesToLocalStorage,
2025-03-29 21:28:05 +01:00
cycleMaxCoins,
getCurrentMaxCoins,
2025-04-08 14:29:00 +02:00
getSettingValue,
2025-03-29 21:28:05 +01:00
getTotalScore,
setSettingValue,
} from "./settings";
2025-03-16 17:45:29 +01:00
import {
2025-03-29 21:28:05 +01:00
forEachLiveOne,
2025-04-15 21:28:00 +02:00
gameStateTick,
liveCount,
2025-03-29 21:28:05 +01:00
normalizeGameState,
pickRandomUpgrades,
setLevel,
setMousePos,
} from "./gameStateMutators";
2025-04-07 14:08:48 +02:00
import {
backgroundCanvas,
gameCanvas,
haloCanvas,
haloScale,
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-04-08 21:54:19 +02:00
import {
2025-04-09 11:28:32 +02:00
catchRateBest,
catchRateGood,
2025-04-10 15:27:38 +02:00
clamp,
2025-04-08 21:54:19 +02:00
hoursSpentPlaying,
levelTimeBest,
2025-04-09 11:28:32 +02:00
levelTimeGood,
missesBest,
missesGood,
2025-04-08 21:54:19 +02:00
wallBouncedBest,
2025-04-09 11:28:32 +02:00
wallBouncedGood,
2025-04-08 21:54:19 +02:00
} from "./pure_functions";
2025-04-07 14:08:48 +02:00
import { helpMenuEntry } from "./help";
import { creativeMode } from "./creative";
import { setupTooltips } from "./tooltip";
import { startingPerkMenuButton } from "./startingPerks";
2025-04-02 17:03:53 +02:00
import "./migrations";
2025-04-07 14:08:48 +02:00
import { getHistory } from "./gameOver";
import { generateSaveFileContent } from "./generateSaveFileContent";
import { runHistoryViewerMenuEntry } from "./runHistoryViewer";
import { getNearestUnlockHTML, openScorePanel } from "./openScorePanel";
2025-04-08 14:03:38 +02:00
import { monitorLevelsUnlocks } from "./monitorLevelsUnlocks";
2025-04-14 13:39:30 +02:00
import { levelEditorMenuEntry } from "./levelEditor";
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-04-18 17:15:47 +02:00
if (gameState.startParams.computer_controlled) 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-04-11 20:34:51 +02:00
export const fitSize = (gameState: GameState) => {
if (!gameState) throw new Error("Missign game state");
2025-03-29 21:28:05 +01:00
const past_off = gameState.offsetXRoundedDown,
past_width = gameState.gameZoneWidthRoundedUp,
past_heigh = gameState.gameZoneHeight;
2025-04-11 20:34:51 +02:00
const width = window.innerWidth,
height = window.innerHeight;
2025-04-11 20:34:11 +02:00
2025-03-29 21:28:05 +01:00
gameState.canvasWidth = width;
gameState.canvasHeight = height;
gameCanvas.width = width;
gameCanvas.height = height;
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")
2025-04-11 20:34:51 +02:00
? Math.floor(height * 0.8)
: height;
2025-04-11 20:34:11 +02:00
2025-03-29 21:28:05 +01:00
const baseWidth = Math.round(
2025-04-11 20:34:51 +02:00
Math.min(
gameState.canvasWidth,
(gameState.gameZoneHeight *
0.73 *
(gameState.gridSize + gameState.perks.unbounded * 2)) /
gameState.gridSize,
),
2025-03-29 21:28:05 +01:00
);
2025-04-11 20:34:11 +02:00
2025-04-11 20:34:51 +02:00
gameState.brickWidth =
Math.floor(
baseWidth / (gameState.gridSize + gameState.perks.unbounded * 2) / 2,
) * 2;
2025-03-29 21:28:05 +01:00
gameState.gameZoneWidth = gameState.brickWidth * gameState.gridSize;
gameState.offsetX = Math.floor(
(gameState.canvasWidth - gameState.gameZoneWidth) / 2,
);
2025-04-11 20:34:11 +02:00
// Space between left side and border
2025-04-11 20:34:51 +02:00
gameState.offsetXRoundedDown =
gameState.offsetX - gameState.perks.unbounded * gameState.brickWidth;
if (
gameState.offsetX <
gameState.ballSize + gameState.perks.unbounded * 2 * gameState.brickWidth
)
gameState.offsetXRoundedDown = 0;
2025-03-29 21:28:05 +01:00
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-04-11 20:34:51 +02:00
window.addEventListener("resize", () => fitSize(gameState));
window.addEventListener("fullscreenchange", () => fitSize(gameState));
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...)
2025-04-12 08:50:28 +02:00
const width = window.innerWidth,
height = window.innerHeight;
2025-03-29 21:28:05 +01:00
if (width !== gameState.canvasWidth || height !== gameState.canvasHeight)
2025-04-11 20:34:11 +02:00
fitSize(gameState);
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-04-08 21:54:19 +02:00
if (gameState.levelWallBounces < wallBouncedBest) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
wallHitsGain = t("level_up.plus_one_upgrade_and_reroll");
2025-04-08 21:54:19 +02:00
} else if (gameState.levelWallBounces < wallBouncedGood) {
repeats++;
2025-03-29 21:28:05 +01:00
wallHitsGain = t("level_up.plus_one_upgrade");
}
2025-04-08 21:54:19 +02:00
if (gameState.levelTime < levelTimeBest * 1000) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
timeGain = t("level_up.plus_one_upgrade_and_reroll");
2025-04-08 21:54:19 +02:00
} else if (gameState.levelTime < levelTimeGood * 1000) {
repeats++;
timeGain = t("level_up.plus_one_upgrade");
2025-03-29 21:28:05 +01:00
}
2025-04-09 11:28:32 +02:00
if (catchRate > catchRateBest / 100) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
catchGain = t("level_up.plus_one_upgrade_and_reroll");
2025-04-09 11:28:32 +02:00
} else if (catchRate > catchRateGood / 100) {
repeats++;
catchGain = t("level_up.plus_one_upgrade");
2025-03-29 21:28:05 +01:00
}
2025-04-08 21:54:19 +02:00
if (gameState.levelMisses < missesBest) {
2025-03-29 21:28:05 +01:00
repeats++;
gameState.rerolls++;
missesGain = t("level_up.plus_one_upgrade_and_reroll");
2025-04-08 21:54:19 +02:00
} else if (gameState.levelMisses < missesGood) {
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,
2025-04-07 14:08:48 +02:00
pickedUpgradesHTMl(gameState),
2025-04-08 14:03:38 +02:00
getNearestUnlockHTML(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-04-15 21:28:00 +02:00
startWork("tick init");
2025-04-15 17:31:57 +02:00
2025-03-29 21:28:05 +01:00
const currentTick = performance.now();
const timeDeltaMs = currentTick - gameState.lastTick;
gameState.lastTick = currentTick;
2025-04-10 15:27:38 +02:00
let frames = Math.min(4, timeDeltaMs / (1000 / 60));
2025-03-29 21:28:05 +01:00
if (gameState.keyboardPuckSpeed) {
setMousePos(
gameState,
gameState.puckPosition + gameState.keyboardPuckSpeed,
);
}
2025-04-10 15:27:38 +02:00
if (gameState.perks.superhot) {
frames *= clamp(
Math.abs(gameState.puckPosition - gameState.lastPuckPosition) / 5,
0.2 / gameState.perks.superhot,
1,
);
}
2025-03-29 21:28:05 +01:00
2025-04-15 21:28:00 +02:00
startWork("normalizeGameState");
2025-04-15 17:31:57 +02:00
normalizeGameState(gameState);
2025-04-15 21:28:00 +02:00
startWork("gameStateTick");
2025-03-29 21:28:05 +01:00
if (gameState.running) {
2025-04-11 20:34:51 +02:00
gameState.levelTime += timeDeltaMs * frames;
gameState.runStatistics.runTime += timeDeltaMs * frames;
2025-04-18 17:15:47 +02:00
const steps = isOptionOn("precise_physics") ? 4 : 1;
for (let i = 0; i < steps; i++) gameStateTick(gameState, frames / steps);
2025-03-29 21:28:05 +01:00
}
2025-04-15 17:31:57 +02:00
2025-03-29 21:28:05 +01:00
if (gameState.running || gameState.needsRender) {
gameState.needsRender = false;
render(gameState);
}
2025-04-15 21:28:00 +02:00
startWork("recordOneFrame");
2025-03-29 21:28:05 +01:00
if (gameState.running) {
recordOneFrame(gameState);
}
2025-04-15 21:28:00 +02:00
startWork("playPendingSounds");
2025-03-29 21:28:05 +01:00
if (isOptionOn("sound")) {
playPendingSounds(gameState);
}
2025-04-15 21:28:00 +02:00
startWork("idle");
2025-03-29 21:28:05 +01:00
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);
2025-04-15 21:28:00 +02:00
const showStats = window.location.search.includes("stress");
let total = {};
let lastTick = performance.now();
let doing = "";
export function startWork(what) {
if (!showStats) return;
const newNow = performance.now();
if (doing) {
total[doing] = (total[doing] || 0) + (newNow - lastTick);
2025-04-15 17:31:57 +02:00
}
2025-04-15 21:28:00 +02:00
lastTick = newNow;
doing = what;
2025-04-15 17:31:57 +02:00
}
2025-04-15 21:28:00 +02:00
if (showStats)
setInterval(() => {
const totalTime = sumOfValues(total);
console.debug(
liveCount(gameState.coins) +
" coins\n" +
Object.entries(total)
.sort((a, b) => b[1] - a[1])
.filter((a) => a[1] > 1)
.map(
(t) =>
t[0] +
":" +
((t[1] / totalTime) * 100).toFixed(2) +
"% (" +
t[1] +
"ms)",
)
.join("\n"),
);
total = {};
}, 2000);
2025-04-15 17:31:57 +02:00
2025-04-08 10:36:30 +02:00
setInterval(() => {
2025-04-08 14:03:38 +02:00
monitorLevelsUnlocks(gameState);
2025-04-08 10:36:30 +02:00
}, 500);
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) {
2025-04-07 14:08:48 +02:00
openScorePanel(gameState);
2025-03-29 21:28:05 +01:00
}
});
document.addEventListener("visibilitychange", () => {
2025-03-29 21:28:05 +01:00
if (document.hidden) {
pause(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>[] = [
{
2025-04-06 10:47:44 +02:00
icon: icons["icon:new_run"],
2025-03-29 21:28:05 +01:00
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 15:38:30 +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-04-07 14:08:48 +02:00
runHistoryViewerMenuEntry(),
2025-04-14 13:39:30 +02:00
levelEditorMenuEntry(),
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"),
2025-04-09 11:28:32 +02:00
content: [
...actions,
2025-04-08 21:54:19 +02:00
`<p>
<span>Made in France by <a href="https://lecaro.me">Renan LE CARO</a>.</span>
<a href="https://paypal.me/renanlecaro" target="_blank">Donate</a>
<a href="https://discord.gg/bbcQw4x5zA" target="_blank">Discord</a>
<a href="https://f-droid.org/en/packages/me.lecaro.breakout/" target="_blank">F-Droid</a>
<a href="https://play.google.com/store/apps/details?id=me.lecaro.breakout" target="_blank">Google Play</a>
<a href="https://renanlecaro.itch.io/breakout71" target="_blank">itch.io</a>
<a href="https://gitlab.com/lecarore/breakout71" target="_blank">Gitlab</a>
<a href="https://breakout.lecaro.me/" target="_blank">Web version</a>
<a href="https://news.ycombinator.com/item?id=43183131" target="_blank">HackerNews</a>
<a href="https://breakout.lecaro.me/privacy.html" target="_blank">Privacy Policy</a>
<a href="https://archive.lecaro.me/public-files/b71/" target="_blank">Archives</a>
<span>v.${appVersion}</span>
2025-04-09 11:28:32 +02:00
</p>`,
2025-04-08 21:54:19 +02:00
],
2025-03-29 21:28:05 +01:00
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");
},
},
];
}
2025-04-12 20:01:43 +02: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:41:35 +02:00
actions.push({
2025-04-09 11:28:32 +02:00
icon: icons[languages.find((l) => l.value === getCurrentLang())?.levelName],
2025-04-11 09:36:31 +02:00
text: t("settings.language"),
help: t("settings.language_help"),
2025-04-02 10:41:35 +02:00
async value() {
const pick = await asyncAlert({
2025-04-11 09:36:31 +02:00
title: t("settings.language"),
2025-04-09 11:28:32 +02:00
content: [
2025-04-11 09:36:31 +02:00
t("settings.language_help"),
2025-04-09 11:28:32 +02:00
...languages.map((l) => ({ ...l, icon: icons[l.levelName] })),
],
2025-04-02 10:41:35 +02:00
allowClose: true,
});
if (
pick &&
pick !== getCurrentLang() &&
(await confirmRestart(gameState))
) {
setSettingValue("lang", pick);
2025-04-16 09:26:10 +02:00
commitSettingsChangesToLocalStorage();
2025-04-02 10:41:35 +02:00
window.location.reload();
}
},
});
2025-03-29 21:28:05 +01:00
for (const key of Object.keys(options) as OptionId[]) {
2025-04-18 17:15:47 +02:00
if (options[key]) {
2025-03-29 21:28:05 +01:00
actions.push({
icon: isOptionOn(key)
2025-04-18 17:15:47 +02:00
? icons["icon:checkmark_checked"]
: icons["icon:checkmark_unchecked"],
2025-03-29 21:28:05 +01:00
text: options[key].name,
help: options[key].help,
2025-04-18 17:15:47 +02:00
disabled : (key=='extra_bright' && isOptionOn('basic')) || (key=='contrast' && isOptionOn('basic')) || false,
2025-03-29 21:28:05 +01:00
value: () => {
toggleOption(key);
2025-04-11 20:34:11 +02:00
fitSize(gameState);
2025-03-29 21:28:05 +01:00
applyFullScreenChoice();
openSettingsMenu();
},
2025-03-29 21:28:05 +01:00
});
2025-04-18 17:15:47 +02:00
}
2025-03-29 21:28:05 +01:00
}
actions.push({
2025-04-02 10:42:01 +02:00
icon: icons["icon:download"],
2025-04-11 09:36:31 +02:00
text: t("settings.download_save_file"),
help: t("settings.download_save_file_help"),
2025-03-29 21:28:05 +01:00
async value() {
2025-04-07 14:08:48 +02:00
const signedPayload = generateSaveFileContent();
2025-03-29 21:28:05 +01:00
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-04-11 09:36:31 +02:00
text: t("settings.load_save_file"),
help: t("settings.load_save_file_help"),
2025-03-29 21:28:05 +01:00
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");
// Actually, loading a save file to an older version is pretty useful
// if (fileVersion > appVersion)
// throw new Error(
// "Please update your app first, this file is for version " +
// fileVersion +
// " or newer.",
// );
2025-03-29 21:28:05 +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.");
}
const localStorageContent = JSON.parse(signedPayload);
localStorage.clear();
for (let key in localStorageContent) {
localStorage.setItem(key, localStorageContent[key]);
}
await asyncAlert({
2025-04-11 09:36:31 +02:00
title: t("settings.save_file_loaded"),
2025-03-27 10:52:31 +01:00
content: [
2025-04-11 09:36:31 +02:00
t("settings.save_file_loaded_help"),
{ text: t("settings.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({
2025-04-11 09:36:31 +02:00
title: t("settings.save_file_error"),
2025-04-11 20:34:51 +02:00
content: [e.message, { text: t("settings.save_file_loaded_ok") }],
2025-03-29 21:28:05 +01:00
});
}
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-04-11 09:36:31 +02:00
text: t("settings.max_coins", { max: getCurrentMaxCoins() }),
help: t("settings.max_coins_help"),
2025-03-29 21:28:05 +01:00
async value() {
cycleMaxCoins();
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-11 09:36:31 +02:00
text: t("settings.reset"),
help: t("settings.reset_help"),
2025-04-02 10:41:35 +02:00
async value() {
if (
await asyncAlert({
2025-04-11 09:36:31 +02:00
title: t("settings.reset"),
2025-04-02 10:41:35 +02:00
content: [
2025-04-11 09:36:31 +02:00
t("settings.reset_instruction"),
2025-04-02 10:41:35 +02:00
{
2025-04-11 09:36:31 +02:00
text: t("settings.reset_confirm"),
2025-04-02 10:41:35 +02:00
value: true,
},
{
2025-04-11 09:36:31 +02:00
text: t("settings.reset_cancel"),
2025-04-02 10:41:35 +02:00
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();
2025-04-06 15:38:30 +02:00
const hintField = isOptionOn("mobile-mode") ? "help" : "tooltip";
2025-03-29 21:28:05 +01:00
const upgradeActions = upgrades
.sort((a, b) => a.threshold - b.threshold)
.map(({ name, id, threshold, icon, help }) => ({
text: name,
disabled: ts < threshold,
2025-04-14 13:39:30 +02:00
value: {
perks: { [id]: 1 },
level: allLevelsAndIcons.find((l) => l.name === "icon:" + id),
} as RunParams,
2025-03-29 21:28:05 +01:00
icon,
2025-04-06 15:38:30 +02:00
[hintField]:
ts < threshold
? t("unlocks.minTotalScore", { score: threshold })
: help(1),
2025-03-29 21:28:05 +01:00
}));
2025-04-08 14:29:00 +02:00
const unlockedBefore = new Set(
getSettingValue("breakout_71_unlocked_levels", []),
);
2025-04-06 15:38:30 +02:00
const levelActions = allLevels.map((l, li) => {
2025-04-08 14:29:00 +02:00
const lockedBecause = unlockedBefore.has(l.name)
? null
: reasonLevelIsLocked(li, getHistory(), true);
2025-04-07 14:08:48 +02:00
const percentUnlocked = lockedBecause?.reached
? `<span class="progress-inline"><span style="transform: scale(${Math.floor((lockedBecause.reached / lockedBecause.minScore) * 100) / 100},1)"></span></span>`
: "";
2025-04-06 15:38:30 +02:00
return {
2025-04-07 14:08:48 +02:00
text: l.name + percentUnlocked,
disabled: !!lockedBecause,
2025-04-14 13:39:30 +02:00
value: { level: l } as RunParams,
2025-04-06 15:38:30 +02:00
icon: icons[l.name],
2025-04-07 14:08:48 +02:00
[hintField]: lockedBecause?.text || describeLevel(l),
2025-04-06 15:38:30 +02:00
};
});
2025-03-29 15:00:44 +01:00
2025-03-29 21:28:05 +01:00
const tryOn = await asyncAlert<RunParams>({
2025-04-06 11:57:52 +02:00
title: t("unlocks.title_upgrades", {
2025-04-06 15:38:30 +02:00
unlocked: upgradeActions.filter((a) => !a.disabled).length,
out_of: upgradeActions.length,
2025-04-06 11:57:52 +02:00
}),
2025-03-29 21:28:05 +01:00
content: [
2025-04-01 13:35:33 +02:00
`<p>${t("unlocks.intro", { ts })}
2025-04-06 15:38:30 +02:00
${upgradeActions.find((u) => u.disabled) ? t("unlocks.greyed_out_help") : ""}</p> `,
2025-03-29 21:28:05 +01:00
...upgradeActions,
2025-04-06 15:38:30 +02:00
t("unlocks.level", {
unlocked: levelActions.filter((a) => !a.disabled).length,
out_of: levelActions.length,
}),
2025-03-29 21:28:05 +01:00
...levelActions,
],
2025-04-04 12:07:51 +02:00
allowClose: true,
2025-04-06 15:38:30 +02:00
className: isOptionOn("mobile-mode") ? "" : "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-03-29 21:28:05 +01:00
if (!gameState.currentLevel) return true;
if (alertsOpen) return true;
2025-04-06 15:38:30 +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) {
2025-04-10 21:40:45 +02:00
openScorePanel(gameState).then();
} else if (
e.key.toLowerCase() === "r" &&
!alertsOpen &&
2025-04-02 10:41:35 +02:00
pageLoad < Date.now() - 500
) {
2025-04-18 17:15:47 +02:00
if (gameState.startParams.computer_controlled) {
2025-04-12 20:58:24 +02:00
return startComputerControlledGame();
}
// 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 15:38:30 +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
Object.assign(gameState, newGameState(params));
// Recompute brick size according to level
2025-04-11 20:34:11 +02:00
fitSize(gameState);
2025-03-29 21:28:05 +01:00
pauseRecording();
setLevel(gameState, 0);
2025-04-12 20:01:43 +02:00
if (params?.computer_controlled) {
play();
}
2025-03-14 12:23:19 +01:00
}
2025-04-15 21:28:00 +02:00
if (window.location.search.match(/autoplay|stress/)) {
2025-04-12 20:01:43 +02:00
startComputerControlledGame();
2025-04-15 21:28:00 +02:00
if (!isOptionOn("show_fps")) toggleOption("show_fps");
} else {
2025-04-12 20:01:43 +02:00
restart({});
}
export function startComputerControlledGame() {
2025-04-15 16:47:04 +02:00
const perks: Partial<PerksMap> = { base_combo: 20, pierce: 3 };
2025-04-15 21:28:00 +02:00
if (window.location.search.includes("stress")) {
Object.assign(perks, {
base_combo: 5000,
pierce: 20,
rainbow: 3,
sapper: 2,
etherealcoins: 1,
bricks_attract_ball: 1,
respawn: 3,
});
} else {
for (let i = 0; i < 10; i++) {
const u = sample(upgrades);
perks[u.id] ||= Math.floor(Math.random() * u.max) + 1;
}
perks.superhot = 0;
2025-04-12 20:01:43 +02:00
}
restart({
2025-04-15 16:47:04 +02:00
level: sample(allLevels.filter((l) => l.color === "#000000")),
2025-04-12 20:01:43 +02:00
computer_controlled: true,
perks,
});
}
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"));