${t("level_up.after_buttons", {
- level: gameState.currentLevel + 1,
- max: max_levels(gameState),
+ level: gameState.currentLevel + 1,
+ max: max_levels(gameState),
})}
${pickedUpgradesHTMl(gameState)}
@@ -239,755 +259,748 @@ export async function openUpgradesPicker(gameState: GameState) {
`;
- 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 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({
- title:
- t("level_up.pick_upgrade_title") +
- (repeats ? " (" + (repeats + 1) + ")" : ""),
- content: [
- `${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,
- })}
+ const upgradeId = await requiredAsyncAlert({
+ title:
+ t("level_up.pick_upgrade_title") +
+ (repeats ? " (" + (repeats + 1) + ")" : ""),
+ content: [
+ `${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,
+ })}
${levelsListHTMl(gameState)}
`,
- ...actions,
- textAfterButtons,
- ],
- });
+ ...actions,
+ textAfterButtons,
+ ],
+ });
- if (upgradeId === "reroll") {
- repeats++;
- gameState.rerolls--;
- } else {
- gameState.perks[upgradeId]++;
- if (upgradeId === "instant_upgrade") {
- repeats += 2;
- }
- gameState.runStatistics.upgrades_picked++;
- }
+ if (upgradeId === "reroll") {
+ repeats++;
+ gameState.rerolls--;
+ } else {
+ gameState.perks[upgradeId]++;
+ if (upgradeId === "instant_upgrade") {
+ repeats += 2;
+ }
+ gameState.runStatistics.upgrades_picked++;
}
+ }
}
gameCanvas.addEventListener("mouseup", (e) => {
- if (e.button !== 0) return;
- if (gameState.running) {
- pause(true);
- } else {
- play();
- if (isOptionOn("pointerLock") && gameCanvas.requestPointerLock) {
- gameCanvas.requestPointerLock().then();
- }
+ if (e.button !== 0) return;
+ if (gameState.running) {
+ pause(true);
+ } else {
+ play();
+ if (isOptionOn("pointerLock") && gameCanvas.requestPointerLock) {
+ gameCanvas.requestPointerLock().then();
}
+ }
});
gameCanvas.addEventListener("mousemove", (e) => {
- if (document.pointerLockElement === gameCanvas) {
- setMousePos(gameState, gameState.puckPosition + e.movementX);
- } else {
- setMousePos(gameState, e.x);
- }
+ if (document.pointerLockElement === gameCanvas) {
+ setMousePos(gameState, gameState.puckPosition + e.movementX);
+ } else {
+ setMousePos(gameState, e.x);
+ }
});
gameCanvas.addEventListener("touchstart", (e) => {
- e.preventDefault();
- if (!e.touches?.length) return;
+ e.preventDefault();
+ if (!e.touches?.length) return;
- setMousePos(gameState, e.touches[0].pageX);
- normalizeGameState(gameState);
- play();
+ setMousePos(gameState, e.touches[0].pageX);
+ normalizeGameState(gameState);
+ play();
});
gameCanvas.addEventListener("touchend", (e) => {
- e.preventDefault();
- pause(true);
+ e.preventDefault();
+ pause(true);
});
gameCanvas.addEventListener("touchcancel", (e) => {
- e.preventDefault();
- pause(true);
+ e.preventDefault();
+ pause(true);
});
gameCanvas.addEventListener("touchmove", (e) => {
- if (!e.touches?.length) return;
- setMousePos(gameState, e.touches[0].pageX);
+ if (!e.touches?.length) return;
+ setMousePos(gameState, e.touches[0].pageX);
});
export function brickIndex(x: number, y: number) {
- return getRowColIndex(
- gameState,
- Math.floor(y / gameState.brickWidth),
- Math.floor((x - gameState.offsetX) / gameState.brickWidth),
- );
+ return getRowColIndex(
+ gameState,
+ Math.floor(y / gameState.brickWidth),
+ Math.floor((x - gameState.offsetX) / gameState.brickWidth),
+ );
}
export function hasBrick(index: number): number | undefined {
- if (gameState.bricks[index]) return index;
+ if (gameState.bricks[index]) return index;
}
export function hitsSomething(x: number, y: number, radius: number) {
- 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))
- );
+ 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))
+ );
}
export function tick() {
- const currentTick = performance.now();
- const timeDeltaMs = currentTick - gameState.lastTick;
- gameState.lastTick = currentTick;
+ const currentTick = performance.now();
+ const timeDeltaMs = currentTick - gameState.lastTick;
+ gameState.lastTick = currentTick;
- const frames = Math.min(4, timeDeltaMs / (1000 / 60));
+ const frames = Math.min(4, timeDeltaMs / (1000 / 60));
- if (gameState.keyboardPuckSpeed) {
- setMousePos(
- gameState,
- gameState.puckPosition + gameState.keyboardPuckSpeed,
- );
- }
- normalizeGameState(gameState);
+ 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);
- }
+ 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++;
+ requestAnimationFrame(tick);
+ FPSCounter++;
}
let FPSCounter = 0;
export let lastMeasuredFPS = 60;
setInterval(() => {
- lastMeasuredFPS = FPSCounter
- FPSCounter = 0;
+ lastMeasuredFPS = FPSCounter;
+ FPSCounter = 0;
}, 1000);
window.addEventListener("visibilitychange", () => {
- if (document.hidden) {
- pause(true);
- }
+ if (document.hidden) {
+ pause(true);
+ }
});
scoreDisplay.addEventListener("click", (e) => {
- e.preventDefault();
- if (!alertsOpen) {
- openScorePanel();
- }
+ e.preventDefault();
+ if (!alertsOpen) {
+ openScorePanel();
+ }
});
document.addEventListener("visibilitychange", () => {
- if (document.hidden) {
- pause(true);
- }
+ if (document.hidden) {
+ pause(true);
+ }
});
async function openScorePanel() {
- pause(true);
+ pause(true);
- const banned = upgrades
- .filter((u) => gameState.bannedPerks[u.id])
- .map((u) => u.name)
- .join(", ");
+ const banned = upgrades
+ .filter((u) => gameState.bannedPerks[u.id])
+ .map((u) => u.name)
+ .join(", ");
+ const cb = await asyncAlert({
+ title: gameState.loop
+ ? t("score_panel.title_looped", {
+ loop: gameState.loop,
+ score: gameState.score,
+ level: gameState.currentLevel + 1,
+ max: max_levels(gameState),
+ })
+ : t("score_panel.title", {
+ score: gameState.score,
+ level: gameState.currentLevel + 1,
+ max: max_levels(gameState),
+ }),
- const cb = await asyncAlert({
- title: gameState.loop
- ? t("score_panel.title_looped", {
- loop: gameState.loop,
- score: gameState.score,
- level: gameState.currentLevel + 1,
- max: max_levels(gameState),
- })
- : t("score_panel.title", {
- score: gameState.score,
- level: gameState.currentLevel + 1,
- max: max_levels(gameState),
- }),
-
- content: [
- gameState.isCreativeModeRun ? `${t("score_panel.test_run")}
` : "",
- pickedUpgradesHTMl(gameState),
- levelsListHTMl(gameState),
- gameState.rerolls ?
- t('score_panel.rerolls_count', {rerolls: gameState.rerolls}) : '',
- banned && t('score_panel.banned', {banned})
- ],
- allowClose: true,
- });
+ content: [
+ gameState.isCreativeModeRun ? `${t("score_panel.test_run")}
` : "",
+ pickedUpgradesHTMl(gameState),
+ levelsListHTMl(gameState),
+ gameState.rerolls
+ ? t("score_panel.rerolls_count", { rerolls: gameState.rerolls })
+ : "",
+ banned && t("score_panel.banned", { banned }),
+ ],
+ allowClose: true,
+ });
}
(document.getElementById("menu") as HTMLButtonElement).addEventListener(
- "click",
- (e) => {
- e.preventDefault();
- if (!alertsOpen) {
- openMainMenu();
- }
- },
+ "click",
+ (e) => {
+ e.preventDefault();
+ if (!alertsOpen) {
+ openMainMenu();
+ }
+ },
);
export async function openMainMenu() {
- pause(true);
+ pause(true);
- const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold));
- const actions: AsyncAlertAction<() => void>[] = [
- {
- icon: icons["icon:7_levels_run"],
- text: t("main_menu.normal"),
- help: t("main_menu.normal_help"),
- value: () => {
- restart({levelToAvoid: currentLevelInfo(gameState).name});
- },
- },
- {
- icon: icons["icon:unlocks"],
- text: t("main_menu.unlocks"),
- help: t("main_menu.unlocks_help"),
- value() {
- openUnlocksList();
- },
- },
- {
- icon: icons["icon:sandbox"],
- 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;
+ const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold));
+ const actions: AsyncAlertAction<() => void>[] = [
+ {
+ icon: icons["icon:7_levels_run"],
+ text: t("main_menu.normal"),
+ help: t("main_menu.normal_help"),
+ value: () => {
+ restart({ levelToAvoid: currentLevelInfo(gameState).name });
+ },
+ },
+ {
+ icon: icons["icon:unlocks"],
+ text: t("main_menu.unlocks"),
+ help: t("main_menu.unlocks_help"),
+ value() {
+ openUnlocksList();
+ },
+ },
+ {
+ icon: icons["icon:sandbox"],
+ 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"),
- actionsAsGrid: true,
- content: [
- t("sandbox.instructions"),
- ...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",
- icon: icons["icon:continue"],
- },
- ],
- }))
- ) {
- if (choice === "start") {
- restart({perks: creativeModePerks});
- break;
- } else if (choice) {
- creativeModePerks[choice.id] =
- ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1);
- setSettingValue("creativeModePerks", creativeModePerks);
- }
- }
- },
- },
+ while (
+ (choice = await asyncAlert<"start" | Upgrade>({
+ title: t("sandbox.title"),
+ actionsAsGrid: true,
+ content: [
+ t("sandbox.instructions"),
+ ...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",
+ icon: icons["icon:continue"],
+ },
+ ],
+ }))
+ ) {
+ if (choice === "start") {
+ restart({ perks: creativeModePerks });
+ break;
+ } else if (choice) {
+ creativeModePerks[choice.id] =
+ ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1);
+ setSettingValue("creativeModePerks", creativeModePerks);
+ }
+ }
+ },
+ },
- premiumMenuEntry(gameState),
- {
- text: t("main_menu.settings_title"),
- help: t("main_menu.settings_help"),
- icon: icons["icon:settings"],
- value() {
- openSettingsMenu();
- },
- },
- ];
+ premiumMenuEntry(gameState),
+ {
+ text: t("main_menu.settings_title"),
+ help: t("main_menu.settings_help"),
+ icon: icons["icon:settings"],
+ value() {
+ openSettingsMenu();
+ },
+ },
+ ];
- 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;
- }
+ 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;
+ }
}
async function openSettingsMenu() {
- pause(true);
+ pause(true);
- const actions: AsyncAlertAction<() => void>[] = [];
+ const actions: AsyncAlertAction<() => void>[] = [];
- 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();
- },
- });
- }
- actions.push({
- 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();
- }
+ 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();
},
- });
-
- actions.push({
- text: t("main_menu.download_save_file"),
- help: t("main_menu.download_save_file_help"),
- async value() {
- const localStorageContent: Record = {};
-
- 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({
- 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((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"),
- content: [
- t("main_menu.save_file_loaded_help"),
- {text: t("main_menu.save_file_loaded_ok")},
- ],
- });
- window.location.reload();
- }
- } 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({
- text: t("main_menu.language"),
- help: t("main_menu.language_help"),
- async value() {
- const pick = await asyncAlert({
- title: t("main_menu.language"),
- content: [
- t("main_menu.language_help"),
- {
- text: "English",
- value: "en",
- },
- {
- text: "Français",
- value: "fr",
- },
- ],
- allowClose: true,
- });
- if (
- pick &&
- pick !== getCurrentLang() &&
- (await confirmRestart(gameState))
- ) {
- setSettingValue("lang", pick);
- window.location.reload();
- }
- },
- });
-
- actions.push({
- text: t("main_menu.max_coins", {max: getCurrentMaxCoins()}),
- help: t("main_menu.max_coins_help"),
- async value() {
- cycleMaxCoins();
- await openSettingsMenu();
- },
- });
- actions.push({
- text: t("main_menu.max_particles", {max: getCurrentMaxParticles()}),
- help: t("main_menu.max_particles_help"),
- async value() {
- cycleMaxParticles();
- await openSettingsMenu();
- },
- });
-
- const cb = await asyncAlert<() => void>({
- title: t("main_menu.settings_title"),
- content: [t("main_menu.settings_help"), ...actions],
- allowClose: true,
- });
- if (cb) {
- cb();
- gameState.needsRender = true;
- }
-}
-
-
-function applyFullScreenChoice(): boolean {
-
- try {
- if (!(document.fullscreenEnabled || document.webkitFullscreenEnabled)) {
- return false
- }
-
- if (document.fullscreenElement !== null && !isOptionOn("fullscreen")) {
- if (document.exitFullscreen) {
- document.exitFullscreen();
- return true
- } else if (document.webkitCancelFullScreen) {
- document.webkitCancelFullScreen();
- return true
- }
- } else if (isOptionOn("fullscreen") && !document.fullscreenElement) {
- const docel = document.documentElement;
- if (docel.requestFullscreen) {
- docel.requestFullscreen();
- return true
- } else if (docel.webkitRequestFullscreen) {
- docel.webkitRequestFullscreen();
- return true
- }
- }
- } catch (e) {
- console.warn(e);
-
- }
- return false
-}
-
-
-async function openUnlocksList() {
- const ts = getTotalScore();
- const upgradeActions = upgrades
- .sort((a, b) => a.threshold - b.threshold)
- .map(({name, id, threshold, icon, help}) => ({
- text: name,
- // help:
- // ts >= threshold ? help(1) : t("unlocks.unlocks_at", { threshold }),
- disabled: ts < threshold,
- value: {perks: {[id]: 1}} as RunParams,
- icon,
- }))
-
- const levelActions = 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],
- };
+ });
+ }
+ actions.push({
+ 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();
+ }
+ },
+ });
- const percentUnlock = Math.round(
- ([...upgradeActions, ...levelActions].filter((a) => !a.disabled).length / (upgradeActions.length +
- levelActions.length)) * 100,
- );
- const tryOn = await asyncAlert({
- title: t("unlocks.title", {percentUnlock}),
+ actions.push({
+ text: t("main_menu.download_save_file"),
+ help: t("main_menu.download_save_file_help"),
+ async value() {
+ const localStorageContent: Record = {};
+
+ 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({
+ 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((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"),
+ content: [
+ t("main_menu.save_file_loaded_help"),
+ { text: t("main_menu.save_file_loaded_ok") },
+ ],
+ });
+ window.location.reload();
+ }
+ } 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({
+ text: t("main_menu.language"),
+ help: t("main_menu.language_help"),
+ async value() {
+ const pick = await asyncAlert({
+ title: t("main_menu.language"),
content: [
- `${t("unlocks.intro", {ts, highScore: gameState.highScore})}
- ${percentUnlock < 100 ? t("unlocks.greyed_out_help") : ""}
`,
- ...upgradeActions,
- t("unlocks.level"),
- ...levelActions,
-
+ t("main_menu.language_help"),
+ {
+ text: "English",
+ value: "en",
+ },
+ {
+ text: "Français",
+ value: "fr",
+ },
],
allowClose: true,
- actionsAsGrid: true,
- });
- if (tryOn) {
- if (await confirmRestart(gameState)) {
- restart(tryOn);
- }
+ });
+ if (
+ pick &&
+ pick !== getCurrentLang() &&
+ (await confirmRestart(gameState))
+ ) {
+ setSettingValue("lang", pick);
+ window.location.reload();
+ }
+ },
+ });
+
+ actions.push({
+ text: t("main_menu.max_coins", { max: getCurrentMaxCoins() }),
+ help: t("main_menu.max_coins_help"),
+ async value() {
+ cycleMaxCoins();
+ await openSettingsMenu();
+ },
+ });
+ actions.push({
+ text: t("main_menu.max_particles", { max: getCurrentMaxParticles() }),
+ help: t("main_menu.max_particles_help"),
+ async value() {
+ cycleMaxParticles();
+ await openSettingsMenu();
+ },
+ });
+
+ const cb = await asyncAlert<() => void>({
+ title: t("main_menu.settings_title"),
+ content: [t("main_menu.settings_help"), ...actions],
+ allowClose: true,
+ });
+ if (cb) {
+ cb();
+ gameState.needsRender = true;
+ }
+}
+
+function applyFullScreenChoice(): boolean {
+ try {
+ if (!(document.fullscreenEnabled || document.webkitFullscreenEnabled)) {
+ return false;
}
+
+ if (document.fullscreenElement !== null && !isOptionOn("fullscreen")) {
+ if (document.exitFullscreen) {
+ document.exitFullscreen();
+ return true;
+ } else if (document.webkitCancelFullScreen) {
+ document.webkitCancelFullScreen();
+ return true;
+ }
+ } else if (isOptionOn("fullscreen") && !document.fullscreenElement) {
+ const docel = document.documentElement;
+ if (docel.requestFullscreen) {
+ docel.requestFullscreen();
+ return true;
+ } else if (docel.webkitRequestFullscreen) {
+ docel.webkitRequestFullscreen();
+ return true;
+ }
+ }
+ } catch (e) {
+ console.warn(e);
+ }
+ return false;
+}
+
+async function openUnlocksList() {
+ const ts = getTotalScore();
+ const upgradeActions = upgrades
+ .sort((a, b) => a.threshold - b.threshold)
+ .map(({ name, id, threshold, icon, help }) => ({
+ text: name,
+ // help:
+ // ts >= threshold ? help(1) : t("unlocks.unlocks_at", { threshold }),
+ disabled: ts < threshold,
+ value: { perks: { [id]: 1 } } as RunParams,
+ icon,
+ }));
+
+ const levelActions = 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(
+ ([...upgradeActions, ...levelActions].filter((a) => !a.disabled).length /
+ (upgradeActions.length + levelActions.length)) *
+ 100,
+ );
+ const tryOn = await asyncAlert({
+ title: t("unlocks.title", { percentUnlock }),
+ content: [
+ `${t("unlocks.intro", { ts, highScore: gameState.highScore })}
+ ${percentUnlock < 100 ? t("unlocks.greyed_out_help") : ""}
`,
+ ...upgradeActions,
+ t("unlocks.level"),
+ ...levelActions,
+ ],
+ allowClose: true,
+ actionsAsGrid: true,
+ });
+ if (tryOn) {
+ if (await confirmRestart(gameState)) {
+ restart(tryOn);
+ }
+ }
}
export async function confirmRestart(gameState) {
- if (!gameState.currentLevel) return true;
+ if (!gameState.currentLevel) return true;
- return asyncAlert({
- title: t("confirmRestart.title"),
- content: [
- t("confirmRestart.text"),
- {
- value: true,
- text: t("confirmRestart.yes"),
- },
- {
- value: false,
- text: t("confirmRestart.no"),
- },
- ],
- });
+ return asyncAlert({
+ title: t("confirmRestart.title"),
+ content: [
+ t("confirmRestart.text"),
+ {
+ value: true,
+ text: t("confirmRestart.yes"),
+ },
+ {
+ value: false,
+ text: t("confirmRestart.no"),
+ },
+ ],
+ });
}
-
const pressed: { [k: string]: number } = {
- ArrowLeft: 0,
- ArrowRight: 0,
- Shift: 0,
+ ArrowLeft: 0,
+ ArrowRight: 0,
+ Shift: 0,
};
export function setKeyPressed(key: string, on: 0 | 1) {
- pressed[key] = on;
- gameState.keyboardPuckSpeed =
- ((pressed.ArrowRight - pressed.ArrowLeft) *
- (1 + pressed.Shift * 2) *
- gameState.gameZoneWidth) /
- 50;
+ pressed[key] = on;
+ gameState.keyboardPuckSpeed =
+ ((pressed.ArrowRight - pressed.ArrowLeft) *
+ (1 + pressed.Shift * 2) *
+ gameState.gameZoneWidth) /
+ 50;
}
document.addEventListener("keydown", (e) => {
- 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 {
- play();
- }
+ 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 {
- return;
+ play();
}
- e.preventDefault();
+ } else {
+ return;
+ }
+ e.preventDefault();
});
document.addEventListener("keyup", async (e) => {
- 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) {
- if (await confirmRestart(gameState)) {
- restart({levelToAvoid: currentLevelInfo(gameState).name});
- }
- } else {
- return;
+ 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) {
+ if (await confirmRestart(gameState)) {
+ restart({ levelToAvoid: currentLevelInfo(gameState).name });
}
- e.preventDefault();
+ } else {
+ return;
+ }
+ e.preventDefault();
});
export const gameState = newGameState({});
export function restart(params: RunParams) {
- fitSize();
- Object.assign(gameState, newGameState(params));
- pauseRecording();
- setLevel(gameState, 0);
+ fitSize();
+ Object.assign(gameState, newGameState(params));
+ pauseRecording();
+ setLevel(gameState, 0);
}
-
restart(
- (window.location.search.includes("stressTest") && {
- level: "Bird",
- perks: {
- shocks:10,
- multiball:6,
- telekinesis:2,
- ghost_coins:1,
- pierce:4,
- clairvoyant:3,
- bigger_explosions:2,
- sapper:2,
- unbounded:1
-
- },
- levelsPerLoop: 2,
- }) ||
+ (window.location.search.includes("stressTest") && {
+ level: "Bird",
+ perks: {
+ shocks: 10,
+ multiball: 6,
+ telekinesis: 2,
+ ghost_coins: 1,
+ pierce: 4,
+ clairvoyant: 3,
+ bigger_explosions: 2,
+ sapper: 2,
+ unbounded: 1,
+ },
+ levelsPerLoop: 2,
+ }) ||
{},
);
diff --git a/src/gameStateMutators.ts b/src/gameStateMutators.ts
index 4d20039..94796ce 100644
--- a/src/gameStateMutators.ts
+++ b/src/gameStateMutators.ts
@@ -1,1815 +1,1834 @@
import {
- Ball,
- BallLike,
- Coin,
- colorString,
- GameState,
- LightFlash,
- ParticleFlash,
- PerkId,
- ReusableArray,
- TextFlash,
+ Ball,
+ BallLike,
+ Coin,
+ colorString,
+ GameState,
+ LightFlash,
+ ParticleFlash,
+ PerkId,
+ ReusableArray,
+ TextFlash,
} from "./types";
import {
- brickCenterX,
- brickCenterY,
- countBricksAbove,
- countBricksBelow,
- currentLevelInfo,
- distance2,
- distanceBetween,
- getMajorityValue,
- getPossibleUpgrades,
- getRowColIndex,
- isTelekinesisActive,
- isYoyoActive,
- makeEmptyPerksMap,
- max_levels,
- shouldPierceByColor,
+ brickCenterX,
+ brickCenterY,
+ countBricksAbove,
+ countBricksBelow,
+ currentLevelInfo,
+ distance2,
+ distanceBetween,
+ getMajorityValue,
+ getPossibleUpgrades,
+ getRowColIndex,
+ isTelekinesisActive,
+ isYoyoActive,
+ makeEmptyPerksMap,
+ max_levels,
+ shouldPierceByColor,
} from "./game_utils";
-import {t} from "./i18n/i18n";
-import {icons, upgrades} from "./loadGameData";
+import { t } from "./i18n/i18n";
+import { icons, upgrades } from "./loadGameData";
-import {addToTotalScore, getCurrentMaxCoins, getCurrentMaxParticles,} from "./settings";
-import {background} from "./render";
-import {gameOver} from "./gameOver";
-import {brickIndex, fitSize, gameState, hasBrick, hitsSomething, openUpgradesPicker, pause,} from "./game";
-import {stopRecording} from "./recording";
-import {isOptionOn} from "./options";
-import {isPremium} from "./premium";
-import {getRunLevels} from "./newGameState";
-import {requiredAsyncAlert} from "./asyncAlert";
-import {clamp, comboKeepingRate} from "./pure_functions";
+import {
+ addToTotalScore,
+ getCurrentMaxCoins,
+ getCurrentMaxParticles,
+} from "./settings";
+import { background } from "./render";
+import { gameOver } from "./gameOver";
+import {
+ brickIndex,
+ fitSize,
+ gameState,
+ hasBrick,
+ hitsSomething,
+ openUpgradesPicker,
+ pause,
+} from "./game";
+import { stopRecording } from "./recording";
+import { isOptionOn } from "./options";
+import { isPremium } from "./premium";
+import { getRunLevels } from "./newGameState";
+import { requiredAsyncAlert } from "./asyncAlert";
+import { clamp, comboKeepingRate } from "./pure_functions";
export function setMousePos(gameState: GameState, x: number) {
- gameState.puckPosition = x;
- // Sets the puck position, and updates the ball position if they are supposed to follow it
- gameState.needsRender = true;
+ gameState.puckPosition = x;
+ // Sets the puck position, and updates the ball position if they are supposed to follow it
+ gameState.needsRender = true;
}
function getBallDefaultVx(gameState: GameState) {
- return (
- (gameState.perks.concave_puck ? 0 : 1) *
- (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed)
- );
+ return (
+ (gameState.perks.concave_puck ? 0 : 1) *
+ (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed)
+ );
}
export function resetBalls(gameState: GameState) {
- // Always compute speed first
- normalizeGameState(gameState);
- const count = 1 + (gameState.perks?.multiball || 0);
- const perBall = gameState.puckWidth / (count + 1);
- gameState.balls = [];
- gameState.ballsColor = "#FFF";
- if (gameState.perks.picky_eater || gameState.perks.pierce_color) {
- gameState.ballsColor =
- getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF";
- }
- for (let i = 0; i < count; i++) {
- const x =
- gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
- const vx = getBallDefaultVx(gameState);
+ // Always compute speed first
+ normalizeGameState(gameState);
+ const count = 1 + (gameState.perks?.multiball || 0);
+ const perBall = gameState.puckWidth / (count + 1);
+ gameState.balls = [];
+ gameState.ballsColor = "#FFF";
+ if (gameState.perks.picky_eater || gameState.perks.pierce_color) {
+ gameState.ballsColor =
+ getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF";
+ }
+ for (let i = 0; i < count; i++) {
+ const x =
+ gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
+ const vx = getBallDefaultVx(gameState);
- gameState.balls.push({
- x,
- previousX: x,
- y: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
- previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
- vx,
- previousVX: vx,
- vy: -gameState.baseSpeed,
- previousVY: -gameState.baseSpeed,
- piercePoints: gameState.perks.pierce * 3,
- hitSinceBounce: 0,
- brokenSinceBounce: 0,
- sapperUses: 0,
- });
- }
- gameState.ballStickToPuck = true;
+ gameState.balls.push({
+ x,
+ previousX: x,
+ y: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
+ previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
+ vx,
+ previousVX: vx,
+ vy: -gameState.baseSpeed,
+ previousVY: -gameState.baseSpeed,
+ piercePoints: gameState.perks.pierce * 3,
+ hitSinceBounce: 0,
+ brokenSinceBounce: 0,
+ sapperUses: 0,
+ });
+ }
+ gameState.ballStickToPuck = true;
}
export function putBallsAtPuck(gameState: GameState) {
- // This reset could be abused to cheat quite easily
- const count = gameState.balls.length;
- const perBall = gameState.puckWidth / (count + 1);
- // const vx = getBallDefaultVx(gameState);
- gameState.balls.forEach((ball, i) => {
- const x =
- gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
+ // This reset could be abused to cheat quite easily
+ const count = gameState.balls.length;
+ const perBall = gameState.puckWidth / (count + 1);
+ // const vx = getBallDefaultVx(gameState);
+ gameState.balls.forEach((ball, i) => {
+ const x =
+ gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
- ball.x = x;
- ball.previousX = x;
- ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize;
- ball.previousY = ball.y;
- ball.hitSinceBounce = 0;
- ball.brokenSinceBounce = 0;
- ball.piercePoints = gameState.perks.pierce * 3;
- });
+ ball.x = x;
+ ball.previousX = x;
+ ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize;
+ ball.previousY = ball.y;
+ ball.hitSinceBounce = 0;
+ ball.brokenSinceBounce = 0;
+ ball.piercePoints = gameState.perks.pierce * 3;
+ });
}
export function normalizeGameState(gameState: GameState) {
- // This function resets most parameters on the state to correct values, and should be used even when the game is paused
+ // This function resets most parameters on the state to correct values, and should be used even when the game is paused
- gameState.baseSpeed = Math.max(
- 3,
- gameState.gameZoneWidth / 12 / 10 +
- gameState.currentLevel / 3 +
- gameState.levelTime / (30 * 1000) -
- gameState.perks.slow_down * 2,
- );
+ gameState.baseSpeed = Math.max(
+ 3,
+ gameState.gameZoneWidth / 12 / 10 +
+ gameState.currentLevel / 3 +
+ gameState.levelTime / (30 * 1000) -
+ gameState.perks.slow_down * 2,
+ );
- gameState.puckWidth = Math.max(gameState.ballSize,
- (gameState.gameZoneWidth / 12) *
- Math.min(12, 3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck));
+ gameState.puckWidth = Math.max(
+ gameState.ballSize,
+ (gameState.gameZoneWidth / 12) *
+ Math.min(
+ 12,
+ 3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck,
+ ),
+ );
- const corner = gameState.levelTime ? gameState.perks.corner_shot : 0
+ const corner = gameState.levelTime ? gameState.perks.corner_shot : 0;
- let minX = gameState.offsetXRoundedDown + gameState.puckWidth / 2 - gameState.puckWidth * corner
+ let minX =
+ gameState.offsetXRoundedDown +
+ gameState.puckWidth / 2 -
+ gameState.puckWidth * corner;
- let maxX = gameState.offsetXRoundedDown +
- gameState.gameZoneWidthRoundedUp -
- gameState.puckWidth / 2 + gameState.puckWidth * corner;
+ let maxX =
+ gameState.offsetXRoundedDown +
+ gameState.gameZoneWidthRoundedUp -
+ gameState.puckWidth / 2 +
+ gameState.puckWidth * corner;
+ gameState.puckPosition = clamp(gameState.puckPosition, minX, maxX);
- gameState.puckPosition = clamp(gameState.puckPosition, minX, maxX);
+ if (gameState.ballStickToPuck) {
+ putBallsAtPuck(gameState);
+ }
- if (gameState.ballStickToPuck) {
- putBallsAtPuck(gameState);
- }
-
- if (
- Math.abs(gameState.lastPuckPosition - gameState.puckPosition) > 1 &&
- gameState.running
- ) {
- gameState.lastPuckMove = gameState.levelTime;
- }
- gameState.lastPuckPosition = gameState.puckPosition;
+ if (
+ Math.abs(gameState.lastPuckPosition - gameState.puckPosition) > 1 &&
+ gameState.running
+ ) {
+ gameState.lastPuckMove = gameState.levelTime;
+ }
+ gameState.lastPuckPosition = gameState.puckPosition;
}
export function baseCombo(gameState: GameState) {
- return (
- gameState.baseCombo +
- gameState.perks.base_combo * 3 +
- gameState.perks.smaller_puck * 5
- );
+ return (
+ gameState.baseCombo +
+ gameState.perks.base_combo * 3 +
+ gameState.perks.smaller_puck * 5
+ );
}
export function resetCombo(
- gameState: GameState,
- x: number | undefined,
- y: number | undefined,
+ gameState: GameState,
+ x: number | undefined,
+ y: number | undefined,
) {
- const prev = gameState.combo;
- gameState.combo = baseCombo(gameState);
+ const prev = gameState.combo;
+ gameState.combo = baseCombo(gameState);
- if (prev > gameState.combo && gameState.perks.soft_reset) {
- gameState.combo += Math.floor(
- (prev - gameState.combo) * comboKeepingRate(gameState.perks.soft_reset)
- );
+ if (prev > gameState.combo && gameState.perks.soft_reset) {
+ gameState.combo += Math.floor(
+ (prev - gameState.combo) * comboKeepingRate(gameState.perks.soft_reset),
+ );
+ }
+ const lost = Math.max(0, prev - gameState.combo);
+ if (lost) {
+ for (let i = 0; i < lost && i < 8; i++) {
+ setTimeout(
+ () => schedulGameSound(gameState, "comboDecrease", x, 1),
+ i * 100,
+ );
}
- const lost = Math.max(0, prev - gameState.combo);
- if (lost) {
- for (let i = 0; i < lost && i < 8; i++) {
- setTimeout(
- () => schedulGameSound(gameState, "comboDecrease", x, 1),
- i * 100,
- );
- }
- if (typeof x !== "undefined" && typeof y !== "undefined") {
-
- makeText(gameState, x, y, "red", "-" + lost, 20, 500 + clamp(lost, 0, 500));
- }
+ if (typeof x !== "undefined" && typeof y !== "undefined") {
+ makeText(
+ gameState,
+ x,
+ y,
+ "red",
+ "-" + lost,
+ 20,
+ 500 + clamp(lost, 0, 500),
+ );
}
- return lost;
+ }
+ return lost;
}
export function decreaseCombo(
- gameState: GameState,
- by: number,
- x: number,
- y: number,
+ gameState: GameState,
+ by: number,
+ x: number,
+ y: number,
) {
- const prev = gameState.combo;
- gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by);
- const lost = Math.max(0, prev - gameState.combo);
+ const prev = gameState.combo;
+ gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by);
+ const lost = Math.max(0, prev - gameState.combo);
- if (lost) {
- schedulGameSound(gameState, "comboDecrease", x, 1);
- if (typeof x !== "undefined" && typeof y !== "undefined") {
- makeText(gameState, x, y, "red", "-" + lost, 20, 400 + lost);
- }
+ if (lost) {
+ schedulGameSound(gameState, "comboDecrease", x, 1);
+ if (typeof x !== "undefined" && typeof y !== "undefined") {
+ makeText(gameState, x, y, "red", "-" + lost, 20, 400 + lost);
}
+ }
}
export function spawnExplosion(
- gameState: GameState,
- count: number,
- x: number,
- y: number,
- color: string,
+ gameState: GameState,
+ count: number,
+ x: number,
+ y: number,
+ color: string,
) {
- if (!!isOptionOn("basic")) return;
+ if (!!isOptionOn("basic")) return;
- if (liveCount(gameState.particles) > getCurrentMaxParticles()) {
- // Avoid freezing when lots of explosion happen at once
- count = 1;
- }
- for (let i = 0; i < count; i++) {
- makeParticle(
- gameState,
+ if (liveCount(gameState.particles) > getCurrentMaxParticles()) {
+ // Avoid freezing when lots of explosion happen at once
+ count = 1;
+ }
+ for (let i = 0; i < count; i++) {
+ makeParticle(
+ gameState,
- x + ((Math.random() - 0.5) * gameState.brickWidth) / 2,
- y + ((Math.random() - 0.5) * gameState.brickWidth) / 2,
- (Math.random() - 0.5) * 30,
- (Math.random() - 0.5) * 30,
- color,
- false,
- );
- }
+ x + ((Math.random() - 0.5) * gameState.brickWidth) / 2,
+ y + ((Math.random() - 0.5) * gameState.brickWidth) / 2,
+ (Math.random() - 0.5) * 30,
+ (Math.random() - 0.5) * 30,
+ color,
+ false,
+ );
+ }
}
export function spawnImplosion(
- gameState: GameState,
- count: number,
- x: number,
- y: number,
- color: string,
+ gameState: GameState,
+ count: number,
+ x: number,
+ y: number,
+ color: string,
) {
- if (!!isOptionOn("basic")) return;
+ if (!!isOptionOn("basic")) return;
- if (liveCount(gameState.particles) > getCurrentMaxParticles()) {
- // Avoid freezing when lots of explosion happen at once
- count = 1;
- }
- for (let i = 0; i < count; i++) {
- const dx = ((Math.random() - 0.5) * gameState.brickWidth) / 2;
- const dy = ((Math.random() - 0.5) * gameState.brickWidth) / 2;
- makeParticle(gameState, x - dx * 10, y - dy * 10, dx, dy, color, false);
- }
+ if (liveCount(gameState.particles) > getCurrentMaxParticles()) {
+ // Avoid freezing when lots of explosion happen at once
+ count = 1;
+ }
+ for (let i = 0; i < count; i++) {
+ const dx = ((Math.random() - 0.5) * gameState.brickWidth) / 2;
+ const dy = ((Math.random() - 0.5) * gameState.brickWidth) / 2;
+ makeParticle(gameState, x - dx * 10, y - dy * 10, dx, dy, color, false);
+ }
}
export function explosionAt(
- gameState: GameState,
- index: number,
- x: number,
- y: number,
- ball: Ball,
- extraSize: number = 0
+ gameState: GameState,
+ index: number,
+ x: number,
+ y: number,
+ ball: Ball,
+ extraSize: number = 0,
) {
- const size = 1 + gameState.perks.bigger_explosions +
- Math.max(0, gameState.perks.implosions - 1) + extraSize
- ;
- schedulGameSound(gameState, "explode", ball.x, 1);
- if (index !== -1) {
- const col = index % gameState.gridSize;
- const row = Math.floor(index / gameState.gridSize);
- // Break bricks around
- for (let dx = -size; dx <= size; dx++) {
- for (let dy = -size; dy <= size; dy++) {
- const i = getRowColIndex(gameState, row + dy, col + dx);
- if (gameState.bricks[i] && i !== -1) {
- // Study bricks resist explosions too
- gameState.brickHP[i]--;
- if (gameState.brickHP[i] <= 0) {
- explodeBrick(gameState, i, ball, true);
- }
- }
- }
+ const size =
+ 1 +
+ gameState.perks.bigger_explosions +
+ Math.max(0, gameState.perks.implosions - 1) +
+ extraSize;
+ schedulGameSound(gameState, "explode", ball.x, 1);
+ if (index !== -1) {
+ const col = index % gameState.gridSize;
+ const row = Math.floor(index / gameState.gridSize);
+ // Break bricks around
+ for (let dx = -size; dx <= size; dx++) {
+ for (let dy = -size; dy <= size; dy++) {
+ const i = getRowColIndex(gameState, row + dy, col + dx);
+ if (gameState.bricks[i] && i !== -1) {
+ // Study bricks resist explosions too
+ gameState.brickHP[i]--;
+ if (gameState.brickHP[i] <= 0) {
+ explodeBrick(gameState, i, ball, true);
+ }
}
+ }
}
+ }
- const factor = gameState.perks.implosions ? -1 : 1;
- // Blow nearby coins
- forEachLiveOne(gameState.coins, (c) => {
- const dx = c.x - x;
- const dy = c.y - y;
- const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy));
- c.vx += (((dx / d2) * 10 * size) / c.weight) * factor;
- c.vy += (((dy / d2) * 10 * size) / c.weight) * factor;
- });
- gameState.lastExplosion = Date.now();
+ const factor = gameState.perks.implosions ? -1 : 1;
+ // Blow nearby coins
+ forEachLiveOne(gameState.coins, (c) => {
+ const dx = c.x - x;
+ const dy = c.y - y;
+ const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy));
+ c.vx += (((dx / d2) * 10 * size) / c.weight) * factor;
+ c.vy += (((dy / d2) * 10 * size) / c.weight) * factor;
+ });
+ gameState.lastExplosion = Date.now();
- // makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150);
- if (gameState.perks.implosions) {
- spawnImplosion(
- gameState,
- 7 * size,
- x,
- y,
- "white",
- );
- } else {
- spawnExplosion(
- gameState,
- 7 * size,
- x,
- y,
- "white",
- );
- }
+ // makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150);
+ if (gameState.perks.implosions) {
+ spawnImplosion(gameState, 7 * size, x, y, "white");
+ } else {
+ spawnExplosion(gameState, 7 * size, x, y, "white");
+ }
- gameState.runStatistics.bricks_broken++;
+ gameState.runStatistics.bricks_broken++;
- if (gameState.perks.zen) {
- resetCombo(gameState, x, y);
- }
+ if (gameState.perks.zen) {
+ resetCombo(gameState, x, y);
+ }
}
export function explodeBrick(
- gameState: GameState,
- index: number,
- ball: Ball,
- isExplosion: boolean,
+ gameState: GameState,
+ index: number,
+ ball: Ball,
+ isExplosion: boolean,
) {
- const color = gameState.bricks[index];
- if (!color) return;
+ const color = gameState.bricks[index];
+ if (!color) return;
- if (color === "black") {
- const x = brickCenterX(gameState, index),
- y = brickCenterY(gameState, index);
+ if (color === "black") {
+ const x = brickCenterX(gameState, index),
+ y = brickCenterY(gameState, index);
- // if (color === "transparent") {
- // schedulGameSound(gameState, "void", x, 1);
- // resetCombo(gameState, x, y);
- // }
- setBrick(gameState, index, "");
- explosionAt(gameState, index, x, y, ball, 0);
- } else if (color) {
- // Even if it bounces we don't want to count that as a miss
+ // if (color === "transparent") {
+ // schedulGameSound(gameState, "void", x, 1);
+ // resetCombo(gameState, x, y);
+ // }
+ setBrick(gameState, index, "");
+ explosionAt(gameState, index, x, y, ball, 0);
+ } else if (color) {
+ // Even if it bounces we don't want to count that as a miss
- // Flashing is take care of by the tick loop
- const x = brickCenterX(gameState, index),
- y = brickCenterY(gameState, index);
+ // Flashing is take care of by the tick loop
+ const x = brickCenterX(gameState, index),
+ y = brickCenterY(gameState, index);
- setBrick(gameState, index, "");
+ setBrick(gameState, index, "");
- let coinsToSpawn = gameState.combo;
- if (gameState.perks.sturdy_bricks) {
- // +10% per level
- coinsToSpawn += Math.ceil(
- ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn,
- );
- }
-
- gameState.levelSpawnedCoins += coinsToSpawn;
- gameState.runStatistics.coins_spawned += coinsToSpawn;
- gameState.runStatistics.bricks_broken++;
- const maxCoins = getCurrentMaxCoins() * (isOptionOn("basic") ? 0.5 : 1);
- const spawnableCoins =
- liveCount(gameState.coins) > getCurrentMaxCoins()
- ? 1
- : Math.floor(maxCoins - liveCount(gameState.coins)) / 3;
-
- const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins));
-
- while (coinsToSpawn > 0) {
- const points = Math.min(pointsPerCoin, coinsToSpawn);
- if (points < 0 || isNaN(points)) {
- console.error({points});
- debugger;
- }
-
- coinsToSpawn -= points;
-
- const cx =
- x +
- (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize),
- cy =
- y +
- (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize);
- makeCoin(
- gameState,
- cx,
- cy,
- ball.previousVX * (0.5 + Math.random()),
- ball.previousVY * (0.5 + Math.random()),
- gameState.perks.metamorphosis || isOptionOn("colorful_coins")
- ? color
- : "gold",
- points,
- );
- }
-
- gameState.combo +=
- gameState.perks.streak_shots +
- gameState.perks.compound_interest +
- gameState.perks.left_is_lava +
- gameState.perks.right_is_lava +
- gameState.perks.top_is_lava +
- gameState.perks.picky_eater +
- gameState.perks.asceticism * 3 +
- gameState.perks.zen +
- gameState.perks.passive_income +
- gameState.perks.nbricks +
- gameState.perks.unbounded;
-
- if (gameState.perks.side_kick) {
- if (Math.abs(ball.vx) > Math.abs(ball.vy)) {
- gameState.combo += gameState.perks.side_kick;
- } else {
- decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y);
- }
- }
-
- if (gameState.perks.reach) {
- if (
- countBricksAbove(gameState, index) &&
- !countBricksBelow(gameState, index)
- ) {
- resetCombo(gameState, x, y);
- } else {
- gameState.combo += gameState.perks.reach;
- }
- }
-
- if (
- gameState.lastPuckMove &&
- gameState.perks.passive_income &&
- gameState.lastPuckMove >
- gameState.levelTime - 250 * gameState.perks.passive_income
- ) {
- resetCombo(gameState, x, y);
- }
-
- if (
- gameState.perks.nbricks &&
- ball.brokenSinceBounce > gameState.perks.nbricks
- ) {
- // We need to reset at each hit, otherwise it's just an OP version of single puck hit streak
- resetCombo(gameState, ball.x, ball.y);
- }
-
- if (!isExplosion) {
- // color change
- if (
- (gameState.perks.picky_eater || gameState.perks.pierce_color) &&
- color !== gameState.ballsColor &&
- color
- ) {
- if (gameState.perks.picky_eater) {
- resetCombo(gameState, ball.x, ball.y);
- }
- schedulGameSound(gameState, "colorChange", ball.x, 0.8);
- gameState.lastExplosion = gameState.levelTime;
- gameState.ballsColor = color;
- if (!isOptionOn("basic")) {
- gameState.balls.forEach((ball) => {
- spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color);
- });
- }
- } else {
- schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1);
- }
- }
- // makeLight(gameState, x, y, color, gameState.brickWidth, 40);
-
- spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color);
+ let coinsToSpawn = gameState.combo;
+ if (gameState.perks.sturdy_bricks) {
+ // +10% per level
+ coinsToSpawn += Math.ceil(
+ ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn,
+ );
}
- if (gameState.perks.respawn && color !== "black" && !gameState.bricks[index]) {
- if (Math.random() < comboKeepingRate(gameState.perks.respawn)) {
- append(gameState.respawns, b => {
- b.color = color
- b.index = index
- b.time = gameState.levelTime + 3 * 1000 / gameState.perks.respawn
- })
- }
+ gameState.levelSpawnedCoins += coinsToSpawn;
+ gameState.runStatistics.coins_spawned += coinsToSpawn;
+ gameState.runStatistics.bricks_broken++;
+ const maxCoins = getCurrentMaxCoins() * (isOptionOn("basic") ? 0.5 : 1);
+ const spawnableCoins =
+ liveCount(gameState.coins) > getCurrentMaxCoins()
+ ? 1
+ : Math.floor(maxCoins - liveCount(gameState.coins)) / 3;
+
+ const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins));
+
+ while (coinsToSpawn > 0) {
+ const points = Math.min(pointsPerCoin, coinsToSpawn);
+ if (points < 0 || isNaN(points)) {
+ console.error({ points });
+ debugger;
+ }
+
+ coinsToSpawn -= points;
+
+ const cx =
+ x +
+ (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize),
+ cy =
+ y +
+ (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize);
+ makeCoin(
+ gameState,
+ cx,
+ cy,
+ ball.previousVX * (0.5 + Math.random()),
+ ball.previousVY * (0.5 + Math.random()),
+ gameState.perks.metamorphosis || isOptionOn("colorful_coins")
+ ? color
+ : "gold",
+ points,
+ );
}
+
+ gameState.combo +=
+ gameState.perks.streak_shots +
+ gameState.perks.compound_interest +
+ gameState.perks.left_is_lava +
+ gameState.perks.right_is_lava +
+ gameState.perks.top_is_lava +
+ gameState.perks.picky_eater +
+ gameState.perks.asceticism * 3 +
+ gameState.perks.zen +
+ gameState.perks.passive_income +
+ gameState.perks.nbricks +
+ gameState.perks.unbounded;
+
+ if (gameState.perks.side_kick) {
+ if (Math.abs(ball.vx) > Math.abs(ball.vy)) {
+ gameState.combo += gameState.perks.side_kick;
+ } else {
+ decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y);
+ }
+ }
+
+ if (gameState.perks.reach) {
+ if (
+ countBricksAbove(gameState, index) &&
+ !countBricksBelow(gameState, index)
+ ) {
+ resetCombo(gameState, x, y);
+ } else {
+ gameState.combo += gameState.perks.reach;
+ }
+ }
+
+ if (
+ gameState.lastPuckMove &&
+ gameState.perks.passive_income &&
+ gameState.lastPuckMove >
+ gameState.levelTime - 250 * gameState.perks.passive_income
+ ) {
+ resetCombo(gameState, x, y);
+ }
+
+ if (
+ gameState.perks.nbricks &&
+ ball.brokenSinceBounce > gameState.perks.nbricks
+ ) {
+ // We need to reset at each hit, otherwise it's just an OP version of single puck hit streak
+ resetCombo(gameState, ball.x, ball.y);
+ }
+
+ if (!isExplosion) {
+ // color change
+ if (
+ (gameState.perks.picky_eater || gameState.perks.pierce_color) &&
+ color !== gameState.ballsColor &&
+ color
+ ) {
+ if (gameState.perks.picky_eater) {
+ resetCombo(gameState, ball.x, ball.y);
+ }
+ schedulGameSound(gameState, "colorChange", ball.x, 0.8);
+ gameState.lastExplosion = gameState.levelTime;
+ gameState.ballsColor = color;
+ if (!isOptionOn("basic")) {
+ gameState.balls.forEach((ball) => {
+ spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color);
+ });
+ }
+ } else {
+ schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1);
+ }
+ }
+ // makeLight(gameState, x, y, color, gameState.brickWidth, 40);
+
+ spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color);
+ }
+
+ if (
+ gameState.perks.respawn &&
+ color !== "black" &&
+ !gameState.bricks[index]
+ ) {
+ if (Math.random() < comboKeepingRate(gameState.perks.respawn)) {
+ append(gameState.respawns, (b) => {
+ b.color = color;
+ b.index = index;
+ b.time = gameState.levelTime + (3 * 1000) / gameState.perks.respawn;
+ });
+ }
+ }
}
export function dontOfferTooSoon(gameState: GameState, id: PerkId) {
- gameState.lastOffered[id] = Math.round(Date.now() / 1000);
+ gameState.lastOffered[id] = Math.round(Date.now() / 1000);
}
export function pickRandomUpgrades(gameState: GameState, count: number) {
- let list = getPossibleUpgrades(gameState)
- .map((u) => ({
- ...u,
- score: Math.random() + (gameState.lastOffered[u.id] || 0),
- }))
- .sort((a, b) => a.score - b.score)
- .filter((u) => gameState.perks[u.id] < u.max)
- .filter((u) => !gameState.bannedPerks[u.id])
- .slice(0, count)
- .sort((a, b) => (a.id > b.id ? 1 : -1));
+ let list = getPossibleUpgrades(gameState)
+ .map((u) => ({
+ ...u,
+ score: Math.random() + (gameState.lastOffered[u.id] || 0),
+ }))
+ .sort((a, b) => a.score - b.score)
+ .filter((u) => gameState.perks[u.id] < u.max)
+ .filter((u) => !gameState.bannedPerks[u.id])
+ .slice(0, count)
+ .sort((a, b) => (a.id > b.id ? 1 : -1));
- list.forEach((u) => {
- dontOfferTooSoon(gameState, u.id);
- });
+ list.forEach((u) => {
+ dontOfferTooSoon(gameState, u.id);
+ });
- return list.map((u) => ({
- text:
- u.name +
- (gameState.perks[u.id]
- ? t("level_up.upgrade_perk_to_level", {
- level: gameState.perks[u.id] + 1,
- })
- : ""),
- icon: icons["icon:" + u.id],
- value: u.id as PerkId,
- help: u.help(gameState.perks[u.id] + 1),
- }));
+ return list.map((u) => ({
+ text:
+ u.name +
+ (gameState.perks[u.id]
+ ? t("level_up.upgrade_perk_to_level", {
+ level: gameState.perks[u.id] + 1,
+ })
+ : ""),
+ icon: icons["icon:" + u.id],
+ value: u.id as PerkId,
+ help: u.help(gameState.perks[u.id] + 1),
+ }));
}
export function schedulGameSound(
- gameState: GameState,
- sound: keyof GameState["aboutToPlaySound"],
- x: number | void,
- vol: number,
+ gameState: GameState,
+ sound: keyof GameState["aboutToPlaySound"],
+ x: number | void,
+ vol: number,
) {
- if (!vol) return;
- x ??= gameState.offsetX + gameState.gameZoneWidth / 2;
- const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number };
+ if (!vol) return;
+ x ??= gameState.offsetX + gameState.gameZoneWidth / 2;
+ const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number };
- ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol);
- ex.vol += vol;
+ ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol);
+ ex.vol += vol;
}
export function addToScore(gameState: GameState, coin: Coin) {
- gameState.score += coin.points;
- gameState.lastScoreIncrease = gameState.levelTime;
- addToTotalScore(gameState, coin.points);
- if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) {
- gameState.highScore = gameState.score;
- localStorage.setItem("breakout-3-hs", gameState.score.toString());
- }
- if (!isOptionOn("basic")) {
- makeParticle(
- gameState,
- coin.previousX,
- coin.previousY,
- (gameState.canvasWidth - coin.x) / 100,
- -coin.y / 100,
- coin.color,
- true,
- gameState.coinSize / 2,
- 100 + Math.random() * 50,
- );
- }
+ gameState.score += coin.points;
+ gameState.lastScoreIncrease = gameState.levelTime;
+ addToTotalScore(gameState, coin.points);
+ if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) {
+ gameState.highScore = gameState.score;
+ localStorage.setItem("breakout-3-hs", gameState.score.toString());
+ }
+ if (!isOptionOn("basic")) {
+ makeParticle(
+ gameState,
+ coin.previousX,
+ coin.previousY,
+ (gameState.canvasWidth - coin.x) / 100,
+ -coin.y / 100,
+ coin.color,
+ true,
+ gameState.coinSize / 2,
+ 100 + Math.random() * 50,
+ );
+ }
- schedulGameSound(gameState, "coinCatch", coin.x, 1);
- gameState.runStatistics.score += coin.points;
- if (gameState.perks.asceticism) {
- resetCombo(gameState, coin.x, coin.y);
- }
+ schedulGameSound(gameState, "coinCatch", coin.x, 1);
+ gameState.runStatistics.score += coin.points;
+ if (gameState.perks.asceticism) {
+ resetCombo(gameState, coin.x, coin.y);
+ }
}
export async function gotoNextLoop(gameState: GameState) {
- pause(false);
- gameState.loop++;
- gameState.runStatistics.loops++;
- gameState.runLevels = getRunLevels(gameState.totalScoreAtRunStart, {});
- gameState.upgradesOfferedFor = -1;
+ pause(false);
+ gameState.loop++;
+ gameState.runStatistics.loops++;
+ gameState.runLevels = getRunLevels(gameState.totalScoreAtRunStart, {});
+ gameState.upgradesOfferedFor = -1;
- let comboText = "";
- if (gameState.rerolls) {
- comboText = t("loop.converted_rerolls", {n: gameState.rerolls});
- gameState.baseCombo += gameState.rerolls;
- gameState.rerolls = 0;
- } else {
- comboText = t("loop.no_rerolls");
+ let comboText = "";
+ if (gameState.rerolls) {
+ comboText = t("loop.converted_rerolls", { n: gameState.rerolls });
+ gameState.baseCombo += gameState.rerolls;
+ gameState.rerolls = 0;
+ } else {
+ comboText = t("loop.no_rerolls");
+ }
+
+ const userPerks = upgrades.filter((u) => gameState.perks[u.id]);
+
+ const keep = await requiredAsyncAlert({
+ title: t("loop.title", { loop: gameState.loop }),
+ content: [
+ t("loop.instructions"),
+ comboText,
+ ...userPerks
+ .filter((u) => u.id !== "instant_upgrade")
+ .map((u) => {
+ return {
+ text:
+ u.name +
+ t("level_up.upgrade_perk_to_level", {
+ level: gameState.perks[u.id] + 1,
+ }),
+ icon: u.icon,
+ value: u.id,
+ help: u.help(gameState.perks[u.id] + 1),
+ };
+ }),
+ ],
+ });
+
+ userPerks.forEach((u) => {
+ if (u.id !== keep) {
+ gameState.bannedPerks[u.id] = 1;
}
+ });
- const userPerks = upgrades.filter((u) => gameState.perks[u.id]);
+ Object.assign(gameState.perks, makeEmptyPerksMap(upgrades), {
+ [keep]: gameState.perks[keep],
+ });
- const keep = await requiredAsyncAlert({
- title: t("loop.title", {loop: gameState.loop}),
- content: [
- t("loop.instructions"),
- comboText,
- ...userPerks
- .filter(u => u.id !== 'instant_upgrade')
- .map((u) => {
- return {
- text:
- u.name +
- t("level_up.upgrade_perk_to_level", {
- level: gameState.perks[u.id] + 1,
- }),
- icon: u.icon,
- value: u.id,
- help: u.help(gameState.perks[u.id] + 1),
- };
- }),
- ],
- });
-
- userPerks.forEach(u => {
- if (u.id !== keep) {
- gameState.bannedPerks[u.id] = 1
- }
- })
-
- Object.assign(gameState.perks, makeEmptyPerksMap(upgrades), {
- [keep]: gameState.perks[keep],
- });
-
- await setLevel(gameState, 0);
+ await setLevel(gameState, 0);
}
export async function setLevel(gameState: GameState, l: number) {
- // Here to alleviate double upgrades issues
- if (gameState.upgradesOfferedFor >= l) {
- debugger;
- return console.warn("Extra upgrade request ignored ");
- }
- gameState.upgradesOfferedFor = l;
- pause(false);
- stopRecording();
- if (l > 0) {
- await openUpgradesPicker(gameState);
- }
- gameState.currentLevel = l;
+ // Here to alleviate double upgrades issues
+ if (gameState.upgradesOfferedFor >= l) {
+ debugger;
+ return console.warn("Extra upgrade request ignored ");
+ }
+ gameState.upgradesOfferedFor = l;
+ pause(false);
+ stopRecording();
+ if (l > 0) {
+ await openUpgradesPicker(gameState);
+ }
+ gameState.currentLevel = l;
- gameState.level = gameState.runLevels[l];
+ gameState.level = gameState.runLevels[l];
- gameState.levelTime = 0;
- gameState.winAt = 0;
- gameState.levelWallBounces = 0;
- gameState.autoCleanUses = 0;
- gameState.lastTickDown = gameState.levelTime;
- gameState.levelStartScore = gameState.score;
- gameState.levelSpawnedCoins = 0;
- gameState.levelLostCoins = 0;
- gameState.levelMisses = 0;
- gameState.runStatistics.levelsPlayed++;
+ gameState.levelTime = 0;
+ gameState.winAt = 0;
+ gameState.levelWallBounces = 0;
+ gameState.autoCleanUses = 0;
+ gameState.lastTickDown = gameState.levelTime;
+ gameState.levelStartScore = gameState.score;
+ gameState.levelSpawnedCoins = 0;
+ gameState.levelLostCoins = 0;
+ gameState.levelMisses = 0;
+ gameState.runStatistics.levelsPlayed++;
- // Reset combo silently
- const finalCombo = gameState.combo;
- gameState.combo = baseCombo(gameState);
- if (gameState.perks.shunt) {
- gameState.combo += Math.round(
- Math.max(
- 0,
- (finalCombo - gameState.combo) * comboKeepingRate(gameState.perks.shunt),
- ),
- );
- }
- gameState.combo += gameState.perks.hot_start * 15;
+ // Reset combo silently
+ const finalCombo = gameState.combo;
+ gameState.combo = baseCombo(gameState);
+ if (gameState.perks.shunt) {
+ gameState.combo += Math.round(
+ Math.max(
+ 0,
+ (finalCombo - gameState.combo) *
+ comboKeepingRate(gameState.perks.shunt),
+ ),
+ );
+ }
+ gameState.combo += gameState.perks.hot_start * 15;
- const lvl = currentLevelInfo(gameState);
- if (lvl.size !== gameState.gridSize) {
- gameState.gridSize = lvl.size;
- fitSize();
- }
- gameState.levelLostCoins += empty(gameState.coins);
- empty(gameState.particles);
- empty(gameState.lights);
- empty(gameState.texts);
- empty(gameState.respawns);
- gameState.bricks = [];
- for (let i = 0; i < lvl.size * lvl.size; i++) {
- setBrick(gameState, i, lvl.bricks[i]);
- }
+ const lvl = currentLevelInfo(gameState);
+ if (lvl.size !== gameState.gridSize) {
+ gameState.gridSize = lvl.size;
+ fitSize();
+ }
+ gameState.levelLostCoins += empty(gameState.coins);
+ empty(gameState.particles);
+ empty(gameState.lights);
+ empty(gameState.texts);
+ empty(gameState.respawns);
+ gameState.bricks = [];
+ for (let i = 0; i < lvl.size * lvl.size; i++) {
+ setBrick(gameState, i, lvl.bricks[i]);
+ }
- // Balls color will depend on most common brick color sometimes
- resetBalls(gameState);
- gameState.needsRender = true;
- // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons
- // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg)
- background.src = "data:image/svg+xml;UTF8," + lvl.svg;
+ // Balls color will depend on most common brick color sometimes
+ resetBalls(gameState);
+ gameState.needsRender = true;
+ // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons
+ // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg)
+ background.src = "data:image/svg+xml;UTF8," + lvl.svg;
}
function setBrick(gameState: GameState, index: number, color: string) {
- gameState.bricks[index] = color || "";
- gameState.brickHP[index] =
- (color === "black" && 1) ||
- (color &&
- 1 + gameState.perks.sturdy_bricks) ||
- 0;
+ gameState.bricks[index] = color || "";
+ gameState.brickHP[index] =
+ (color === "black" && 1) ||
+ (color && 1 + gameState.perks.sturdy_bricks) ||
+ 0;
}
export function rainbowColor(): colorString {
- return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`;
+ return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`;
}
export function repulse(
- gameState: GameState,
- a: Ball,
- b: BallLike,
- power: number,
- impactsBToo: boolean,
+ gameState: GameState,
+ a: Ball,
+ b: BallLike,
+ power: number,
+ impactsBToo: boolean,
) {
- const distance = distanceBetween(a, b);
- // Ensure we don't get soft locked
- const max = gameState.gameZoneWidth / 4;
- if (distance > max) return;
- // Unit vector
- const dx = (a.x - b.x) / distance;
- const dy = (a.y - b.y) / distance;
- const fact =
- (((-power * (max - distance)) / (max * 1.2) / 3) *
- Math.min(500, gameState.levelTime)) /
- 500;
- if (
- impactsBToo &&
- typeof b.vx !== "undefined" &&
- typeof b.vy !== "undefined"
- ) {
- b.vx += dx * fact;
- b.vy += dy * fact;
- }
- a.vx -= dx * fact;
- a.vy -= dy * fact;
+ const distance = distanceBetween(a, b);
+ // Ensure we don't get soft locked
+ const max = gameState.gameZoneWidth / 4;
+ if (distance > max) return;
+ // Unit vector
+ const dx = (a.x - b.x) / distance;
+ const dy = (a.y - b.y) / distance;
+ const fact =
+ (((-power * (max - distance)) / (max * 1.2) / 3) *
+ Math.min(500, gameState.levelTime)) /
+ 500;
+ if (
+ impactsBToo &&
+ typeof b.vx !== "undefined" &&
+ typeof b.vy !== "undefined"
+ ) {
+ b.vx += dx * fact;
+ b.vy += dy * fact;
+ }
+ a.vx -= dx * fact;
+ a.vy -= dy * fact;
- const speed = 10;
- const rand = 2;
+ const speed = 10;
+ const rand = 2;
+ makeParticle(
+ gameState,
+ a.x,
+ a.y,
+ -dx * speed + a.vx + (Math.random() - 0.5) * rand,
+ -dy * speed + a.vy + (Math.random() - 0.5) * rand,
+ rainbowColor(),
+ true,
+ gameState.coinSize / 2,
+ 100,
+ );
+ if (
+ impactsBToo &&
+ typeof b.vx !== "undefined" &&
+ typeof b.vy !== "undefined"
+ ) {
makeParticle(
- gameState,
- a.x,
- a.y,
- -dx * speed + a.vx + (Math.random() - 0.5) * rand,
- -dy * speed + a.vy + (Math.random() - 0.5) * rand,
- rainbowColor(),
- true,
- gameState.coinSize / 2,
- 100,
+ gameState,
+ b.x,
+ b.y,
+ dx * speed + b.vx + (Math.random() - 0.5) * rand,
+ dy * speed + b.vy + (Math.random() - 0.5) * rand,
+ rainbowColor(),
+ true,
+ gameState.coinSize / 2,
+ 100,
);
- if (
- impactsBToo &&
- typeof b.vx !== "undefined" &&
- typeof b.vy !== "undefined"
- ) {
- makeParticle(
- gameState,
- b.x,
- b.y,
- dx * speed + b.vx + (Math.random() - 0.5) * rand,
- dy * speed + b.vy + (Math.random() - 0.5) * rand,
- rainbowColor(),
- true,
- gameState.coinSize / 2,
- 100,
- );
- }
+ }
}
export function attract(gameState: GameState, a: Ball, b: Ball, power: number) {
- const distance = distanceBetween(a, b);
- // Ensure we don't get soft locked
- const min = (gameState.gameZoneWidth * 3) / 4;
- if (distance < min) return;
- // Unit vector
- const dx = (a.x - b.x) / distance;
- const dy = (a.y - b.y) / distance;
+ const distance = distanceBetween(a, b);
+ // Ensure we don't get soft locked
+ const min = (gameState.gameZoneWidth * 3) / 4;
+ if (distance < min) return;
+ // Unit vector
+ const dx = (a.x - b.x) / distance;
+ const dy = (a.y - b.y) / distance;
- const fact =
- (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) /
- 500;
- b.vx += dx * fact;
- b.vy += dy * fact;
- a.vx -= dx * fact;
- a.vy -= dy * fact;
+ const fact =
+ (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) /
+ 500;
+ b.vx += dx * fact;
+ b.vy += dy * fact;
+ a.vx -= dx * fact;
+ a.vy -= dy * fact;
- const speed = 10;
- const rand = 2;
+ const speed = 10;
+ const rand = 2;
- makeParticle(
- gameState,
- a.x,
- a.y,
- dx * speed + a.vx + (Math.random() - 0.5) * rand,
- dy * speed + a.vy + (Math.random() - 0.5) * rand,
- rainbowColor(),
- true,
- gameState.coinSize / 2,
- 100,
- );
- makeParticle(
- gameState,
- b.x,
- b.y,
- -dx * speed + b.vx + (Math.random() - 0.5) * rand,
- -dy * speed + b.vy + (Math.random() - 0.5) * rand,
- rainbowColor(),
- true,
- gameState.coinSize / 2,
- 100,
- );
+ makeParticle(
+ gameState,
+ a.x,
+ a.y,
+ dx * speed + a.vx + (Math.random() - 0.5) * rand,
+ dy * speed + a.vy + (Math.random() - 0.5) * rand,
+ rainbowColor(),
+ true,
+ gameState.coinSize / 2,
+ 100,
+ );
+ makeParticle(
+ gameState,
+ b.x,
+ b.y,
+ -dx * speed + b.vx + (Math.random() - 0.5) * rand,
+ -dy * speed + b.vy + (Math.random() - 0.5) * rand,
+ rainbowColor(),
+ true,
+ gameState.coinSize / 2,
+ 100,
+ );
}
export function coinBrickHitCheck(gameState: GameState, coin: Coin) {
- // Make ball/coin bonce, and return bricks that were hit
- const radius = coin.size / 2;
- const {x, y, previousX, previousY} = coin;
+ // 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;
+ 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 (gameState.perks.ghost_coins) {
- // slow down
- if (typeof (vhit ?? hhit ?? chit) !== "undefined") {
-
- coin.vy *= 1 - 0.2 / gameState.perks.ghost_coins;
- coin.vx *= 1 - 0.2 / gameState.perks.ghost_coins;
- }
-
- } else {
- 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;
- }
- if (!leftHit && rightHit) {
- coin.vx -= 1;
- coin.sa += 1;
- }
- }
- if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
- coin.x = coin.previousX;
- coin.vx *= -1;
- }
+ if (gameState.perks.ghost_coins) {
+ // slow down
+ if (typeof (vhit ?? hhit ?? chit) !== "undefined") {
+ coin.vy *= 1 - 0.2 / gameState.perks.ghost_coins;
+ coin.vx *= 1 - 0.2 / gameState.perks.ghost_coins;
}
- return vhit ?? hhit ?? chit;
+ } else {
+ 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;
+ }
+ if (!leftHit && rightHit) {
+ coin.vx -= 1;
+ coin.sa += 1;
+ }
+ }
+ if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
+ coin.x = coin.previousX;
+ coin.vx *= -1;
+ }
+ }
+ return vhit ?? hhit ?? chit;
}
export function bordersHitCheck(
- gameState: GameState,
- coin: Coin | Ball,
- radius: number,
- delta: number,
+ gameState: GameState,
+ coin: Coin | Ball,
+ radius: number,
+ delta: number,
) {
- if (coin.destroyed) return;
- coin.previousX = coin.x;
- coin.previousY = coin.y;
- coin.x += coin.vx * delta;
- coin.y += coin.vy * delta;
+ if (coin.destroyed) return;
+ coin.previousX = coin.x;
+ coin.previousY = coin.y;
+ coin.x += coin.vx * delta;
+ coin.y += coin.vy * delta;
- if (gameState.perks.wind) {
- coin.vx +=
- ((gameState.puckPosition -
- (gameState.offsetX + gameState.gameZoneWidth / 2)) /
- gameState.gameZoneWidth) *
- gameState.perks.wind *
- 0.5;
- }
+ 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;
+ let vhit = 0,
+ hhit = 0;
- if (
- coin.x < gameState.offsetXRoundedDown + radius &&
- !gameState.perks.unbounded
- ) {
- coin.x =
- gameState.offsetXRoundedDown +
- radius +
- (gameState.offsetXRoundedDown + radius - coin.x);
- coin.vx *= -1;
- hhit = 1;
- }
- if (coin.y < radius && gameState.perks.unbounded < 2) {
- coin.y = radius + (radius - coin.y);
- coin.vy *= -1;
- vhit = 1;
- }
- if (
- coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius &&
- !gameState.perks.unbounded
- ) {
- coin.x =
- gameState.canvasWidth -
- gameState.offsetXRoundedDown -
- radius -
- (coin.x -
- (gameState.canvasWidth - gameState.offsetXRoundedDown - radius));
- coin.vx *= -1;
- hhit = 1;
- }
+ if (
+ coin.x < gameState.offsetXRoundedDown + radius &&
+ !gameState.perks.unbounded
+ ) {
+ coin.x =
+ gameState.offsetXRoundedDown +
+ radius +
+ (gameState.offsetXRoundedDown + radius - coin.x);
+ coin.vx *= -1;
+ hhit = 1;
+ }
+ if (coin.y < radius && gameState.perks.unbounded < 2) {
+ coin.y = radius + (radius - coin.y);
+ coin.vy *= -1;
+ vhit = 1;
+ }
+ if (
+ coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius &&
+ !gameState.perks.unbounded
+ ) {
+ coin.x =
+ gameState.canvasWidth -
+ gameState.offsetXRoundedDown -
+ radius -
+ (coin.x -
+ (gameState.canvasWidth - gameState.offsetXRoundedDown - radius));
+ coin.vx *= -1;
+ hhit = 1;
+ }
- return hhit + vhit * 2;
+ return hhit + vhit * 2;
}
export function gameStateTick(
- gameState: GameState,
- // How many frames to compute at once, can go above 1 to compensate lag
- frames = 1,
+ gameState: GameState,
+ // How many frames to compute at once, can go above 1 to compensate lag
+ frames = 1,
) {
- gameState.runStatistics.max_combo = Math.max(
- gameState.runStatistics.max_combo,
- gameState.combo,
+ gameState.runStatistics.max_combo = Math.max(
+ gameState.runStatistics.max_combo,
+ gameState.combo,
+ );
+
+ gameState.balls = gameState.balls.filter((ball) => !ball.destroyed);
+
+ const remainingBricks = gameState.bricks.filter(
+ (b) => b && b !== "black",
+ ).length;
+
+ if (
+ gameState.levelTime > gameState.lastTickDown + 1000 &&
+ gameState.perks.hot_start
+ ) {
+ gameState.lastTickDown = gameState.levelTime;
+ decreaseCombo(
+ gameState,
+ gameState.perks.hot_start,
+ gameState.puckPosition,
+ gameState.gameZoneHeight - 2 * gameState.puckHeight,
);
+ }
- gameState.balls = gameState.balls.filter((ball) => !ball.destroyed);
+ if (
+ remainingBricks <= gameState.perks.skip_last &&
+ !gameState.autoCleanUses
+ ) {
+ gameState.bricks.forEach((type, index) => {
+ if (type) {
+ explodeBrick(gameState, index, gameState.balls[0], true);
+ }
+ });
+ gameState.autoCleanUses++;
+ }
- const remainingBricks = gameState.bricks.filter(
- (b) => b && b !== "black",
- ).length;
+ const hasPendingBricks = liveCount(gameState.respawns);
- if (
- gameState.levelTime > gameState.lastTickDown + 1000 &&
- gameState.perks.hot_start
- ) {
- gameState.lastTickDown = gameState.levelTime;
- decreaseCombo(
- gameState,
- gameState.perks.hot_start,
- gameState.puckPosition,
- gameState.gameZoneHeight - 2 * gameState.puckHeight,
- );
+ if (gameState.running && !remainingBricks && !hasPendingBricks) {
+ if (!gameState.winAt) {
+ gameState.winAt = gameState.levelTime + 5000;
}
+ } else {
+ gameState.winAt = 0;
+ }
- if (
- remainingBricks <= gameState.perks.skip_last &&
- !gameState.autoCleanUses
- ) {
- gameState.bricks.forEach((type, index) => {
- if (type) {
- explodeBrick(gameState, index, gameState.balls[0], true);
- }
- });
- gameState.autoCleanUses++;
- }
-
- const hasPendingBricks = liveCount(gameState.respawns)
-
- if (gameState.running && !remainingBricks && !hasPendingBricks) {
- if (!gameState.winAt) {
- gameState.winAt = gameState.levelTime + 5000;
- }
+ if (
+ (gameState.running &&
+ // Delayed win when coins are still flying
+ gameState.winAt &&
+ gameState.levelTime > gameState.winAt) ||
+ // instant win condition
+ (gameState.levelTime && !remainingBricks && !liveCount(gameState.coins))
+ ) {
+ if (gameState.currentLevel + 1 < max_levels(gameState)) {
+ setLevel(gameState, gameState.currentLevel + 1);
} else {
- gameState.winAt = 0;
+ if (isPremium()) {
+ gotoNextLoop(gameState);
+ } else {
+ gameOver(
+ t("gameOver.win.title"),
+ t("gameOver.win.summary", { score: gameState.score }),
+ );
+ }
}
+ } else if (gameState.running || gameState.levelTime) {
+ const coinRadius = Math.round(gameState.coinSize / 2);
- if (
- (gameState.running &&
- // Delayed win when coins are still flying
- gameState.winAt &&
- gameState.levelTime > gameState.winAt) ||
- // instant win condition
- (gameState.levelTime && !remainingBricks && !liveCount(gameState.coins))
- ) {
- if (gameState.currentLevel + 1 < max_levels(gameState)) {
- setLevel(gameState, gameState.currentLevel + 1);
- } else {
- if (isPremium()) {
- gotoNextLoop(gameState);
- } else {
- gameOver(
- t("gameOver.win.title"),
- t("gameOver.win.summary", {score: gameState.score}),
- );
- }
- }
- } else if (gameState.running || gameState.levelTime) {
- const coinRadius = Math.round(gameState.coinSize / 2);
+ forEachLiveOne(gameState.coins, (coin, coinIndex) => {
+ if (gameState.perks.coin_magnet) {
+ const strength =
+ (100 /
+ (100 +
+ Math.pow(coin.y - gameState.gameZoneHeight, 2) +
+ Math.pow(coin.x - gameState.puckPosition, 2))) *
+ gameState.perks.coin_magnet;
- forEachLiveOne(gameState.coins, (coin, coinIndex) => {
- if (gameState.perks.coin_magnet) {
- const strength =
- (100 /
- (100 +
- Math.pow(coin.y - gameState.gameZoneHeight, 2) +
- Math.pow(coin.x - gameState.puckPosition, 2))) *
- gameState.perks.coin_magnet;
+ const attractionX =
+ frames * (gameState.puckPosition - coin.x) * strength;
- const attractionX =
- frames * (gameState.puckPosition - coin.x) * strength;
+ coin.vx += attractionX;
+ coin.vy +=
+ (frames * (gameState.gameZoneHeight - coin.y) * strength) / 2;
+ coin.sa -= attractionX / 10;
+ }
- coin.vx += attractionX;
- coin.vy +=
- (frames * (gameState.gameZoneHeight - coin.y) * strength) / 2;
- coin.sa -= attractionX / 10;
- }
-
- if (gameState.perks.ball_attracts_coins) {
- gameState.balls.forEach((ball) => {
- const d2 = distance2(ball, coin);
- coin.vx +=
- ((ball.x - coin.x) / d2) * 50 * gameState.perks.ball_attracts_coins;
- coin.vy +=
- ((ball.y - coin.y) / d2) * 50 * gameState.perks.ball_attracts_coins;
- });
- }
-
- const ratio =
- 1 -
- (gameState.perks.viscosity *
- 0.03 +
- 0.005) *
- frames / (1 + gameState.perks.etherealcoins);
-
- coin.vy *= ratio;
- coin.vx *= ratio;
- if (coin.vx > 7 * gameState.baseSpeed) coin.vx = 7 * gameState.baseSpeed;
- if (coin.vx < -7 * gameState.baseSpeed)
- coin.vx = -7 * gameState.baseSpeed;
- if (coin.vy > 7 * gameState.baseSpeed) coin.vy = 7 * gameState.baseSpeed;
- if (coin.vy < -7 * gameState.baseSpeed)
- coin.vy = -7 * gameState.baseSpeed;
- coin.a += coin.sa;
-
- // Gravity
- if (!gameState.perks.etherealcoins) {
- const flip =
- gameState.perks.helium > 0 &&
- Math.abs(coin.x - gameState.puckPosition) * 2 >
- gameState.puckWidth + coin.size;
- coin.vy += frames * coin.weight * 0.8 * (flip ? -gameState.perks.helium : 1);
- if (flip && !isOptionOn("basic") && Math.random() < 0.1) {
- makeParticle(
- gameState,
- coin.x,
- coin.y,
- 0,
- gameState.baseSpeed,
- coin.color,
- true,
- 5,
- 250,
- );
- }
- }
-
-
- const speed = (Math.abs(coin.vx) + Math.abs(coin.vy)) * 10;
- const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames);
-
- if (
- coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight &&
- coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy &&
- Math.abs(coin.x - gameState.puckPosition) <
- coinRadius +
- gameState.puckWidth / 2 +
- // a bit of margin to be nice , negative in case it's a negative coin
- gameState.puckHeight * (coin.points ? 1 : -1)
- ) {
-
- addToScore(gameState, coin);
-
- destroy(gameState.coins, coinIndex);
- } else if (coin.y > gameState.canvasHeight + coinRadius) {
- gameState.levelLostCoins+=coin.points
- destroy(gameState.coins, coinIndex);
- if (gameState.perks.compound_interest) {
- resetCombo(gameState, coin.x, coin.y);
- }
- } else if (
- gameState.perks.unbounded &&
- (coin.x < -gameState.gameZoneWidth / 2 ||
- coin.x > gameState.canvasWidth + gameState.gameZoneWidth / 2
- || coin.y < -gameState.gameZoneWidth
- )
- ) {
- // Out of bound on sides
- gameState.levelLostCoins+=coin.points
- destroy(gameState.coins, coinIndex);
- }
-
- const hitBrick = coinBrickHitCheck(gameState, coin);
- if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") {
- if (
- gameState.bricks[hitBrick] &&
- coin.color !== gameState.bricks[hitBrick] &&
- gameState.bricks[hitBrick] !== "black" &&
- coin.metamorphosisPoints
- ) {
- // Not using setbrick because we don't want to reset HP
- gameState.bricks[hitBrick] = coin.color;
- coin.metamorphosisPoints--;
-
- schedulGameSound(gameState, "colorChange", coin.x, 0.3);
- }
- }
-
- if (
- (!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") ||
- hitBorder
- ) {
- coin.vx *= 0.8;
- coin.vy *= 0.8;
- coin.sa *= 0.9;
- if (speed > 20 && !coin.collidedLastFrame) {
- schedulGameSound(gameState, "coinBounce", coin.x, 0.2);
- }
- coin.collidedLastFrame = true;
-
- if (Math.abs(coin.vy) < 3) {
- coin.vy = 0;
- }
- } else {
- coin.collidedLastFrame = false;
- }
+ if (gameState.perks.ball_attracts_coins) {
+ gameState.balls.forEach((ball) => {
+ const d2 = distance2(ball, coin);
+ coin.vx +=
+ ((ball.x - coin.x) / d2) * 50 * gameState.perks.ball_attracts_coins;
+ coin.vy +=
+ ((ball.y - coin.y) / d2) * 50 * gameState.perks.ball_attracts_coins;
});
+ }
- gameState.balls.forEach((ball) => ballTick(gameState, ball, frames));
+ const ratio =
+ 1 -
+ ((gameState.perks.viscosity * 0.03 + 0.005) * frames) /
+ (1 + gameState.perks.etherealcoins);
- if (gameState.perks.shocks) {
- gameState.balls.forEach((a, ai) =>
- gameState.balls.forEach((b, bi) => {
- if (
- ai < bi &&
- !a.destroyed &&
- !b.destroyed &&
- distance2(a, b) < gameState.ballSize * gameState.ballSize
- ) {
- let tempVx = a.vx;
- let tempVy = a.vy;
- a.vx = b.vx;
- a.vy = b.vy;
- b.vx = tempVx;
- b.vy = tempVy;
+ coin.vy *= ratio;
+ coin.vx *= ratio;
+ if (coin.vx > 7 * gameState.baseSpeed) coin.vx = 7 * gameState.baseSpeed;
+ if (coin.vx < -7 * gameState.baseSpeed)
+ coin.vx = -7 * gameState.baseSpeed;
+ if (coin.vy > 7 * gameState.baseSpeed) coin.vy = 7 * gameState.baseSpeed;
+ if (coin.vy < -7 * gameState.baseSpeed)
+ coin.vy = -7 * gameState.baseSpeed;
+ coin.a += coin.sa;
- let x = (a.x + b.x) / 2;
- let y = (a.y + b.y) / 2;
- const limit = gameState.baseSpeed;
- a.vx +=
- clamp(a.x - x, -limit, limit) +
- ((Math.random() - 0.5) * limit) / 3;
- a.vy +=
- clamp(a.y - y, -limit, limit) +
- ((Math.random() - 0.5) * limit) / 3;
- b.vx +=
- clamp(b.x - x, -limit, limit) +
- ((Math.random() - 0.5) * limit) / 3;
- b.vy +=
- clamp(b.y - y, -limit, limit) +
- ((Math.random() - 0.5) * limit) / 3;
-
- let index = brickIndex(x, y);
- explosionAt(gameState, index, x, y, a, Math.max(0, gameState.perks.shocks - 1));
- }
- }),
- );
+ // Gravity
+ if (!gameState.perks.etherealcoins) {
+ const flip =
+ gameState.perks.helium > 0 &&
+ Math.abs(coin.x - gameState.puckPosition) * 2 >
+ gameState.puckWidth + coin.size;
+ coin.vy +=
+ frames * coin.weight * 0.8 * (flip ? -gameState.perks.helium : 1);
+ if (flip && !isOptionOn("basic") && Math.random() < 0.1) {
+ makeParticle(
+ gameState,
+ coin.x,
+ coin.y,
+ 0,
+ gameState.baseSpeed,
+ coin.color,
+ true,
+ 5,
+ 250,
+ );
}
+ }
- if (gameState.perks.wind) {
- const windD =
- ((gameState.puckPosition -
- (gameState.offsetX + gameState.gameZoneWidth / 2)) /
- gameState.gameZoneWidth) *
- 2 *
- gameState.perks.wind;
- for (let i = 0; i < gameState.perks.wind; i++) {
- if (Math.random() * Math.abs(windD) > 0.5) {
- makeParticle(
- gameState,
- gameState.offsetXRoundedDown +
- Math.random() * gameState.gameZoneWidthRoundedUp,
- Math.random() * gameState.gameZoneHeight,
- windD * 8,
- 0,
- rainbowColor(),
- true,
- gameState.coinSize / 2,
- 150,
- );
- }
- }
- }
- forEachLiveOne(gameState.particles, (flash, index) => {
- flash.x += flash.vx * frames;
- flash.y += flash.vy * frames;
- if (!flash.ethereal) {
- flash.vy += 0.5;
- if (hasBrick(brickIndex(flash.x, flash.y))) {
- destroy(gameState.particles, index);
- }
- }
- });
- }
+ const speed = (Math.abs(coin.vx) + Math.abs(coin.vy)) * 10;
+ const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames);
- if (
- gameState.combo > baseCombo(gameState) &&
- !isOptionOn("basic") &&
- (gameState.combo - baseCombo(gameState)) * Math.random() > 5
- ) {
- // The red should still be visible on a white bg
-
- if (gameState.perks.top_is_lava) {
- makeParticle(
- gameState,
- gameState.offsetXRoundedDown +
- Math.random() * gameState.gameZoneWidthRoundedUp,
- 0,
- (Math.random() - 0.5) * 10,
- 5,
- "red",
- true,
- gameState.coinSize / 2,
- 100 * (Math.random() + 1),
- );
- }
-
- if (gameState.perks.left_is_lava) {
- makeParticle(
- gameState,
- gameState.offsetXRoundedDown,
- Math.random() * gameState.gameZoneHeight,
- 5,
- (Math.random() - 0.5) * 10,
- "red",
- true,
- gameState.coinSize / 2,
- 100 * (Math.random() + 1),
- );
- }
-
- if (gameState.perks.right_is_lava) {
- makeParticle(
- gameState,
- gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp,
- Math.random() * gameState.gameZoneHeight,
- -5,
- (Math.random() - 0.5) * 10,
- "red",
- true,
- gameState.coinSize / 2,
- 100 * (Math.random() + 1),
- );
- }
+ if (
+ coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight &&
+ coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy &&
+ Math.abs(coin.x - gameState.puckPosition) <
+ coinRadius +
+ gameState.puckWidth / 2 +
+ // a bit of margin to be nice , negative in case it's a negative coin
+ gameState.puckHeight * (coin.points ? 1 : -1)
+ ) {
+ addToScore(gameState, coin);
+ destroy(gameState.coins, coinIndex);
+ } else if (coin.y > gameState.canvasHeight + coinRadius) {
+ gameState.levelLostCoins += coin.points;
+ destroy(gameState.coins, coinIndex);
if (gameState.perks.compound_interest) {
- let x = gameState.puckPosition,
- attemps = 0;
- do {
- x =
- gameState.offsetXRoundedDown +
- gameState.gameZoneWidthRoundedUp * Math.random();
- attemps++;
- } while (
- Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 &&
- attemps < 10
- );
+ resetCombo(gameState, coin.x, coin.y);
+ }
+ } else if (
+ gameState.perks.unbounded &&
+ (coin.x < -gameState.gameZoneWidth / 2 ||
+ coin.x > gameState.canvasWidth + gameState.gameZoneWidth / 2 ||
+ coin.y < -gameState.gameZoneWidth)
+ ) {
+ // Out of bound on sides
+ gameState.levelLostCoins += coin.points;
+ destroy(gameState.coins, coinIndex);
+ }
- makeParticle(
- gameState,
- x,
- gameState.gameZoneHeight,
- (Math.random() - 0.5) * 10,
- -5,
- "red",
- true,
- gameState.coinSize / 2,
- 100 * (Math.random() + 1),
- );
+ const hitBrick = coinBrickHitCheck(gameState, coin);
+ if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") {
+ if (
+ gameState.bricks[hitBrick] &&
+ coin.color !== gameState.bricks[hitBrick] &&
+ gameState.bricks[hitBrick] !== "black" &&
+ coin.metamorphosisPoints
+ ) {
+ // Not using setbrick because we don't want to reset HP
+ gameState.bricks[hitBrick] = coin.color;
+ coin.metamorphosisPoints--;
+
+ schedulGameSound(gameState, "colorChange", coin.x, 0.3);
}
- if (gameState.perks.streak_shots) {
- const pos = 0.5 - Math.random();
- makeParticle(
- gameState,
- gameState.puckPosition + gameState.puckWidth * pos,
- gameState.gameZoneHeight - gameState.puckHeight,
- pos * 10,
- -5,
- "red",
- true,
- gameState.coinSize / 2,
- 100 * (Math.random() + 1),
- );
+ }
+
+ if (
+ (!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") ||
+ hitBorder
+ ) {
+ coin.vx *= 0.8;
+ coin.vy *= 0.8;
+ coin.sa *= 0.9;
+ if (speed > 20 && !coin.collidedLastFrame) {
+ schedulGameSound(gameState, "coinBounce", coin.x, 0.2);
}
+ coin.collidedLastFrame = true;
+
+ if (Math.abs(coin.vy) < 3) {
+ coin.vy = 0;
+ }
+ } else {
+ coin.collidedLastFrame = false;
+ }
+ });
+
+ gameState.balls.forEach((ball) => ballTick(gameState, ball, frames));
+
+ if (gameState.perks.shocks) {
+ gameState.balls.forEach((a, ai) =>
+ gameState.balls.forEach((b, bi) => {
+ if (
+ ai < bi &&
+ !a.destroyed &&
+ !b.destroyed &&
+ distance2(a, b) < gameState.ballSize * gameState.ballSize
+ ) {
+ let tempVx = a.vx;
+ let tempVy = a.vy;
+ a.vx = b.vx;
+ a.vy = b.vy;
+ b.vx = tempVx;
+ b.vy = tempVy;
+
+ let x = (a.x + b.x) / 2;
+ let y = (a.y + b.y) / 2;
+ const limit = gameState.baseSpeed;
+ a.vx +=
+ clamp(a.x - x, -limit, limit) +
+ ((Math.random() - 0.5) * limit) / 3;
+ a.vy +=
+ clamp(a.y - y, -limit, limit) +
+ ((Math.random() - 0.5) * limit) / 3;
+ b.vx +=
+ clamp(b.x - x, -limit, limit) +
+ ((Math.random() - 0.5) * limit) / 3;
+ b.vy +=
+ clamp(b.y - y, -limit, limit) +
+ ((Math.random() - 0.5) * limit) / 3;
+
+ let index = brickIndex(x, y);
+ explosionAt(
+ gameState,
+ index,
+ x,
+ y,
+ a,
+ Math.max(0, gameState.perks.shocks - 1),
+ );
+ }
+ }),
+ );
}
- // Respawn what's needed, show particles
- forEachLiveOne(gameState.respawns, (r, ri) => {
- if (gameState.bricks[r.index]) {
- destroy(gameState.respawns, ri)
- } else if (gameState.levelTime > r.time) {
- setBrick(gameState, r.index, r.color)
- destroy(gameState.respawns, ri)
- } else if (!isOptionOn("basic")) {
- const {index, color} = r;
- const vertical = Math.random() > 0.5;
- const dx = Math.random() > 0.5 ? 1 : -1;
- const dy = Math.random() > 0.5 ? 1 : -1;
-
- makeParticle(
- gameState,
- brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2,
- brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2,
- vertical ? 0 : -dx * gameState.baseSpeed,
- vertical ? -dy * gameState.baseSpeed : 0,
- color,
- true,
- gameState.coinSize / 2,
- 250,
- );
+ if (gameState.perks.wind) {
+ const windD =
+ ((gameState.puckPosition -
+ (gameState.offsetX + gameState.gameZoneWidth / 2)) /
+ gameState.gameZoneWidth) *
+ 2 *
+ gameState.perks.wind;
+ for (let i = 0; i < gameState.perks.wind; i++) {
+ if (Math.random() * Math.abs(windD) > 0.5) {
+ makeParticle(
+ gameState,
+ gameState.offsetXRoundedDown +
+ Math.random() * gameState.gameZoneWidthRoundedUp,
+ Math.random() * gameState.gameZoneHeight,
+ windD * 8,
+ 0,
+ rainbowColor(),
+ true,
+ gameState.coinSize / 2,
+ 150,
+ );
}
- })
-
-
- forEachLiveOne(gameState.particles, (p, pi) => {
- if (gameState.levelTime > p.time + p.duration) {
- destroy(gameState.particles, pi);
- }
- });
- forEachLiveOne(gameState.texts, (p, pi) => {
- if (gameState.levelTime > p.time + p.duration) {
- destroy(gameState.texts, pi);
- }
- });
- forEachLiveOne(gameState.lights, (p, pi) => {
- if (gameState.levelTime > p.time + p.duration) {
- destroy(gameState.lights, pi);
+ }
+ }
+ forEachLiveOne(gameState.particles, (flash, index) => {
+ flash.x += flash.vx * frames;
+ flash.y += flash.vy * frames;
+ if (!flash.ethereal) {
+ flash.vy += 0.5;
+ if (hasBrick(brickIndex(flash.x, flash.y))) {
+ destroy(gameState.particles, index);
}
+ }
});
+ }
+
+ if (
+ gameState.combo > baseCombo(gameState) &&
+ !isOptionOn("basic") &&
+ (gameState.combo - baseCombo(gameState)) * Math.random() > 5
+ ) {
+ // The red should still be visible on a white bg
+
+ if (gameState.perks.top_is_lava) {
+ makeParticle(
+ gameState,
+ gameState.offsetXRoundedDown +
+ Math.random() * gameState.gameZoneWidthRoundedUp,
+ 0,
+ (Math.random() - 0.5) * 10,
+ 5,
+ "red",
+ true,
+ gameState.coinSize / 2,
+ 100 * (Math.random() + 1),
+ );
+ }
+
+ if (gameState.perks.left_is_lava) {
+ makeParticle(
+ gameState,
+ gameState.offsetXRoundedDown,
+ Math.random() * gameState.gameZoneHeight,
+ 5,
+ (Math.random() - 0.5) * 10,
+ "red",
+ true,
+ gameState.coinSize / 2,
+ 100 * (Math.random() + 1),
+ );
+ }
+
+ if (gameState.perks.right_is_lava) {
+ makeParticle(
+ gameState,
+ gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp,
+ Math.random() * gameState.gameZoneHeight,
+ -5,
+ (Math.random() - 0.5) * 10,
+ "red",
+ true,
+ gameState.coinSize / 2,
+ 100 * (Math.random() + 1),
+ );
+ }
+
+ if (gameState.perks.compound_interest) {
+ let x = gameState.puckPosition,
+ attemps = 0;
+ do {
+ x =
+ gameState.offsetXRoundedDown +
+ gameState.gameZoneWidthRoundedUp * Math.random();
+ attemps++;
+ } while (
+ Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 &&
+ attemps < 10
+ );
+
+ makeParticle(
+ gameState,
+ x,
+ gameState.gameZoneHeight,
+ (Math.random() - 0.5) * 10,
+ -5,
+ "red",
+ true,
+ gameState.coinSize / 2,
+ 100 * (Math.random() + 1),
+ );
+ }
+ if (gameState.perks.streak_shots) {
+ const pos = 0.5 - Math.random();
+ makeParticle(
+ gameState,
+ gameState.puckPosition + gameState.puckWidth * pos,
+ gameState.gameZoneHeight - gameState.puckHeight,
+ pos * 10,
+ -5,
+ "red",
+ true,
+ gameState.coinSize / 2,
+ 100 * (Math.random() + 1),
+ );
+ }
+ }
+
+ // Respawn what's needed, show particles
+ forEachLiveOne(gameState.respawns, (r, ri) => {
+ if (gameState.bricks[r.index]) {
+ destroy(gameState.respawns, ri);
+ } else if (gameState.levelTime > r.time) {
+ setBrick(gameState, r.index, r.color);
+ destroy(gameState.respawns, ri);
+ } else if (!isOptionOn("basic")) {
+ const { index, color } = r;
+ const vertical = Math.random() > 0.5;
+ const dx = Math.random() > 0.5 ? 1 : -1;
+ const dy = Math.random() > 0.5 ? 1 : -1;
+
+ makeParticle(
+ gameState,
+ brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2,
+ brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2,
+ vertical ? 0 : -dx * gameState.baseSpeed,
+ vertical ? -dy * gameState.baseSpeed : 0,
+ color,
+ true,
+ gameState.coinSize / 2,
+ 250,
+ );
+ }
+ });
+
+ forEachLiveOne(gameState.particles, (p, pi) => {
+ if (gameState.levelTime > p.time + p.duration) {
+ destroy(gameState.particles, pi);
+ }
+ });
+ forEachLiveOne(gameState.texts, (p, pi) => {
+ if (gameState.levelTime > p.time + p.duration) {
+ destroy(gameState.texts, pi);
+ }
+ });
+ forEachLiveOne(gameState.lights, (p, pi) => {
+ if (gameState.levelTime > p.time + p.duration) {
+ destroy(gameState.lights, pi);
+ }
+ });
}
export function ballTick(gameState: GameState, ball: Ball, delta: number) {
- ball.previousVX = ball.vx;
- ball.previousVY = ball.vy;
+ ball.previousVX = ball.vx;
+ ball.previousVY = ball.vy;
- let speedLimitDampener =
- 1 +
- gameState.perks.telekinesis +
- gameState.perks.ball_repulse_ball +
- gameState.perks.puck_repulse_ball +
- gameState.perks.ball_attract_ball;
+ let speedLimitDampener =
+ 1 +
+ gameState.perks.telekinesis +
+ gameState.perks.ball_repulse_ball +
+ gameState.perks.puck_repulse_ball +
+ gameState.perks.ball_attract_ball;
- if (isTelekinesisActive(gameState, ball)) {
- speedLimitDampener += 3;
- ball.vx +=
- ((gameState.puckPosition - ball.x) / 1000) *
- delta *
- gameState.perks.telekinesis
- }
- if (isYoyoActive(gameState, ball)) {
- speedLimitDampener += 3;
- ball.vx +=
- ((gameState.puckPosition - ball.x) / 1000) *
- delta *
- gameState.perks.yoyo
- }
- if (
- ball.vx * ball.vx + ball.vy * ball.vy <
- gameState.baseSpeed * gameState.baseSpeed * 2
- ) {
- ball.vx *= 1 + 0.02 / speedLimitDampener;
- ball.vy *= 1 + 0.02 / speedLimitDampener;
- } else {
- ball.vx *= 1 - 0.02 / speedLimitDampener;
- ball.vy *= 1 - 0.02 / speedLimitDampener;
- }
- // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract
- if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) {
- ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener;
- }
+ if (isTelekinesisActive(gameState, ball)) {
+ speedLimitDampener += 3;
+ ball.vx +=
+ ((gameState.puckPosition - ball.x) / 1000) *
+ delta *
+ gameState.perks.telekinesis;
+ }
+ if (isYoyoActive(gameState, ball)) {
+ speedLimitDampener += 3;
+ ball.vx +=
+ ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo;
+ }
+ if (
+ ball.vx * ball.vx + ball.vy * ball.vy <
+ gameState.baseSpeed * gameState.baseSpeed * 2
+ ) {
+ ball.vx *= 1 + 0.02 / speedLimitDampener;
+ ball.vy *= 1 + 0.02 / speedLimitDampener;
+ } else {
+ ball.vx *= 1 - 0.02 / speedLimitDampener;
+ ball.vy *= 1 - 0.02 / speedLimitDampener;
+ }
+ // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract
+ if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) {
+ ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener;
+ }
- if (gameState.perks.ball_repulse_ball) {
- for (let b2 of gameState.balls) {
- // avoid computing this twice, and repulsing itself
- if (b2.x >= ball.x) continue;
- repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true);
- }
+ if (gameState.perks.ball_repulse_ball) {
+ for (let b2 of gameState.balls) {
+ // avoid computing this twice, and repulsing itself
+ if (b2.x >= ball.x) continue;
+ repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true);
}
- if (gameState.perks.ball_attract_ball) {
- for (let b2 of gameState.balls) {
- // avoid computing this twice, and repulsing itself
- if (b2.x >= ball.x) continue;
- attract(gameState, ball, b2, gameState.perks.ball_attract_ball);
- }
+ }
+ if (gameState.perks.ball_attract_ball) {
+ for (let b2 of gameState.balls) {
+ // avoid computing this twice, and repulsing itself
+ if (b2.x >= ball.x) continue;
+ attract(gameState, ball, b2, gameState.perks.ball_attract_ball);
}
- if (
- gameState.perks.puck_repulse_ball &&
- Math.abs(ball.x - gameState.puckPosition) <
- gameState.puckWidth / 2 +
+ }
+ if (
+ gameState.perks.puck_repulse_ball &&
+ Math.abs(ball.x - gameState.puckPosition) <
+ gameState.puckWidth / 2 +
(gameState.ballSize * (9 + gameState.perks.puck_repulse_ball)) / 10
- ) {
- repulse(
- gameState,
- ball,
- {
- x: gameState.puckPosition,
- y: gameState.gameZoneHeight,
- },
- gameState.perks.puck_repulse_ball + 1,
- false,
- );
- }
-
-
- const borderHitCode = bordersHitCheck(
- gameState,
- ball,
- gameState.ballSize / 2,
- delta,
+ ) {
+ repulse(
+ gameState,
+ ball,
+ {
+ x: gameState.puckPosition,
+ y: gameState.gameZoneHeight,
+ },
+ gameState.perks.puck_repulse_ball + 1,
+ false,
);
- if (borderHitCode) {
- if (
- gameState.perks.left_is_lava &&
- borderHitCode % 2 &&
- ball.x < gameState.offsetX + gameState.gameZoneWidth / 2
- ) {
- resetCombo(gameState, ball.x, ball.y);
- }
+ }
- if (
- gameState.perks.right_is_lava &&
- borderHitCode % 2 &&
- ball.x > gameState.offsetX + gameState.gameZoneWidth / 2
- ) {
- resetCombo(gameState, ball.x, ball.y);
- }
-
- if (gameState.perks.top_is_lava && borderHitCode >= 2) {
- resetCombo(gameState, ball.x, ball.y + gameState.ballSize);
- }
- if (gameState.perks.trampoline && borderHitCode >= 2) {
- decreaseCombo(
- gameState,
- gameState.perks.trampoline,
- ball.x,
- ball.y + gameState.ballSize,
- );
- }
-
- schedulGameSound(gameState, "wallBeep", ball.x, 1);
- gameState.levelWallBounces++;
- gameState.runStatistics.wall_bounces++;
- }
-
- // Puck collision
- const ylimit =
- gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2;
- const ballIsUnderPuck =
- Math.abs(ball.x - gameState.puckPosition) <
- gameState.ballSize / 2 + gameState.puckWidth / 2;
+ const borderHitCode = bordersHitCheck(
+ gameState,
+ ball,
+ gameState.ballSize / 2,
+ delta,
+ );
+ if (borderHitCode) {
if (
- ball.y > ylimit &&
- ball.vy > 0 &&
- (ballIsUnderPuck ||
- (gameState.perks.extra_life &&
- ball.y > ylimit + gameState.puckHeight / 2))
+ gameState.perks.left_is_lava &&
+ borderHitCode % 2 &&
+ ball.x < gameState.offsetX + gameState.gameZoneWidth / 2
) {
- if (ballIsUnderPuck) {
- const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
- const angle = Math.atan2(
- -gameState.puckWidth / 2,
- (ball.x - gameState.puckPosition) *
- (gameState.perks.concave_puck ? -1 / (1 + gameState.perks.concave_puck) : 1),
- );
- ball.vx = speed * Math.cos(angle);
- ball.vy = speed * Math.sin(angle);
- schedulGameSound(gameState, "wallBeep", ball.x, 1);
- } else {
- ball.vy *= -1;
- justLostALife(gameState, ball, ball.x, ball.y);
- }
- if (gameState.perks.streak_shots) {
- resetCombo(gameState, ball.x, ball.y);
- }
- if (gameState.perks.trampoline) {
- gameState.combo += gameState.perks.trampoline;
- }
- if (
- gameState.perks.nbricks &&
- ball.brokenSinceBounce < gameState.perks.nbricks
- ) {
- resetCombo(gameState, ball.x, ball.y);
- }
-
-
- if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) {
- gameState.runStatistics.misses++;
- if (gameState.perks.forgiving) {
- const loss = Math.floor(
- (gameState.levelMisses / 10 / gameState.perks.forgiving) *
- (gameState.combo - baseCombo(gameState)),
- );
- decreaseCombo(gameState, loss, ball.x, ball.y - gameState.ballSize);
- } else {
- resetCombo(gameState, ball.x, ball.y);
- }
- gameState.levelMisses++;
- makeText(
- gameState,
- gameState.puckPosition,
- gameState.gameZoneHeight - gameState.puckHeight * 2,
- "red",
- t("play.missed_ball"),
- gameState.puckHeight,
- 500,
- );
- }
- gameState.runStatistics.puck_bounces++;
- ball.hitSinceBounce = 0;
- ball.brokenSinceBounce = 0;
- ball.sapperUses = 0;
- ball.piercePoints = gameState.perks.pierce * 3;
+ resetCombo(gameState, ball.x, ball.y);
}
- const lostOnSides =
- (gameState.perks.unbounded && ball.x < -gameState.gameZoneWidth / 2) ||
- ball.x > gameState.canvasWidth + gameState.gameZoneWidth / 2;
-
- const lostInTheSky = (gameState.perks.unbounded > 1 &&
- ball.y < -gameState.gameZoneWidth / 2
- )
-
if (
- gameState.running &&
- (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides
- || lostInTheSky
- )
+ gameState.perks.right_is_lava &&
+ borderHitCode % 2 &&
+ ball.x > gameState.offsetX + gameState.gameZoneWidth / 2
) {
- ball.destroyed = true;
- gameState.runStatistics.balls_lost++;
- if (!gameState.balls.find((b) => !b.destroyed)) {
- gameOver(
- t("gameOver.lost.title"),
- t("gameOver.lost.summary", {score: gameState.score}),
- );
- }
+ resetCombo(gameState, ball.x, ball.y);
}
- const radius = gameState.ballSize / 2;
- // Make ball/coin bonce, and return bricks that were hit
- const {x, y, previousX, previousY} = ball;
- 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 (gameState.perks.top_is_lava && borderHitCode >= 2) {
+ resetCombo(gameState, ball.x, ball.y + gameState.ballSize);
+ }
+ if (gameState.perks.trampoline && borderHitCode >= 2) {
+ decreaseCombo(
+ gameState,
+ gameState.perks.trampoline,
+ ball.x,
+ ball.y + gameState.ballSize,
+ );
+ }
- const hitBrick = vhit ?? hhit ?? chit;
+ schedulGameSound(gameState, "wallBeep", ball.x, 1);
+ gameState.levelWallBounces++;
+ gameState.runStatistics.wall_bounces++;
+ }
- if (typeof hitBrick !== "undefined") {
- ball.hitSinceBounce++;
- let pierce = false;
- let damage =
- 1 +
- (shouldPierceByColor(gameState, vhit, hhit, chit)
- ? gameState.perks.pierce_color
- : 0);
+ // Puck collision
+ const ylimit =
+ gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2;
+ const ballIsUnderPuck =
+ Math.abs(ball.x - gameState.puckPosition) <
+ gameState.ballSize / 2 + gameState.puckWidth / 2;
+ if (
+ ball.y > ylimit &&
+ ball.vy > 0 &&
+ (ballIsUnderPuck ||
+ (gameState.perks.extra_life &&
+ ball.y > ylimit + gameState.puckHeight / 2))
+ ) {
+ if (ballIsUnderPuck) {
+ const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy);
+ const angle = Math.atan2(
+ -gameState.puckWidth / 2,
+ (ball.x - gameState.puckPosition) *
+ (gameState.perks.concave_puck
+ ? -1 / (1 + gameState.perks.concave_puck)
+ : 1),
+ );
+ ball.vx = speed * Math.cos(angle);
+ ball.vy = speed * Math.sin(angle);
+ schedulGameSound(gameState, "wallBeep", ball.x, 1);
+ } else {
+ ball.vy *= -1;
+ justLostALife(gameState, ball, ball.x, ball.y);
+ }
+ if (gameState.perks.streak_shots) {
+ resetCombo(gameState, ball.x, ball.y);
+ }
+ if (gameState.perks.trampoline) {
+ gameState.combo += gameState.perks.trampoline;
+ }
+ if (
+ gameState.perks.nbricks &&
+ ball.brokenSinceBounce < gameState.perks.nbricks
+ ) {
+ resetCombo(gameState, ball.x, ball.y);
+ }
- gameState.brickHP[hitBrick] -= damage;
-
- const used = Math.min(
- ball.piercePoints,
- Math.max(1, gameState.brickHP[hitBrick]),
+ if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) {
+ gameState.runStatistics.misses++;
+ if (gameState.perks.forgiving) {
+ const loss = Math.floor(
+ (gameState.levelMisses / 10 / gameState.perks.forgiving) *
+ (gameState.combo - baseCombo(gameState)),
);
- gameState.brickHP[hitBrick] -= used;
- ball.piercePoints -= used;
+ decreaseCombo(gameState, loss, ball.x, ball.y - gameState.ballSize);
+ } else {
+ resetCombo(gameState, ball.x, ball.y);
+ }
+ gameState.levelMisses++;
+ makeText(
+ gameState,
+ gameState.puckPosition,
+ gameState.gameZoneHeight - gameState.puckHeight * 2,
+ "red",
+ t("play.missed_ball"),
+ gameState.puckHeight,
+ 500,
+ );
+ }
+ gameState.runStatistics.puck_bounces++;
+ ball.hitSinceBounce = 0;
+ ball.brokenSinceBounce = 0;
+ ball.sapperUses = 0;
+ ball.piercePoints = gameState.perks.pierce * 3;
+ }
- if (gameState.brickHP[hitBrick] < 0) {
- gameState.brickHP[hitBrick] = 0;
- pierce = true;
- }
- if (typeof vhit !== "undefined" || typeof chit !== "undefined") {
- if (!pierce) {
- ball.y = ball.previousY;
- ball.vy *= -1;
- }
- }
- if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
- if (!pierce) {
- ball.x = ball.previousX;
- ball.vx *= -1;
- }
- }
+ const lostOnSides =
+ (gameState.perks.unbounded && ball.x < -gameState.gameZoneWidth / 2) ||
+ ball.x > gameState.canvasWidth + gameState.gameZoneWidth / 2;
- if (!gameState.brickHP[hitBrick]) {
- const initialBrickColor = gameState.bricks[hitBrick];
- ball.brokenSinceBounce++;
+ const lostInTheSky =
+ gameState.perks.unbounded > 1 && ball.y < -gameState.gameZoneWidth / 2;
- explodeBrick(gameState, hitBrick, ball, false);
- if (
- ball.sapperUses < gameState.perks.sapper &&
- initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks
- !gameState.bricks[hitBrick]
- ) {
- setBrick(gameState, hitBrick, "black");
- ball.sapperUses++;
- }
- } else {
- schedulGameSound(gameState, "wallBeep", x, 1);
- makeLight(
- gameState,
- brickCenterX(gameState, hitBrick),
- brickCenterY(gameState, hitBrick),
- "white",
- gameState.brickWidth + 2,
- 50 * gameState.brickHP[hitBrick],
- );
- }
+ if (
+ gameState.running &&
+ (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 ||
+ lostOnSides ||
+ lostInTheSky)
+ ) {
+ ball.destroyed = true;
+ gameState.runStatistics.balls_lost++;
+ if (!gameState.balls.find((b) => !b.destroyed)) {
+ gameOver(
+ t("gameOver.lost.title"),
+ t("gameOver.lost.summary", { score: gameState.score }),
+ );
+ }
+ }
+ const radius = gameState.ballSize / 2;
+ // Make ball/coin bonce, and return bricks that were hit
+ const { x, y, previousX, previousY } = ball;
+
+ 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;
+
+ const hitBrick = vhit ?? hhit ?? chit;
+
+ if (typeof hitBrick !== "undefined") {
+ ball.hitSinceBounce++;
+ let pierce = false;
+ let damage =
+ 1 +
+ (shouldPierceByColor(gameState, vhit, hhit, chit)
+ ? gameState.perks.pierce_color
+ : 0);
+
+ gameState.brickHP[hitBrick] -= damage;
+
+ const used = Math.min(
+ ball.piercePoints,
+ Math.max(1, gameState.brickHP[hitBrick]),
+ );
+ gameState.brickHP[hitBrick] -= used;
+ ball.piercePoints -= used;
+
+ if (gameState.brickHP[hitBrick] < 0) {
+ gameState.brickHP[hitBrick] = 0;
+ pierce = true;
+ }
+ if (typeof vhit !== "undefined" || typeof chit !== "undefined") {
+ if (!pierce) {
+ ball.y = ball.previousY;
+ ball.vy *= -1;
+ }
+ }
+ if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
+ if (!pierce) {
+ ball.x = ball.previousX;
+ ball.vx *= -1;
+ }
}
- if (!isOptionOn("basic")) {
- const remainingPierce = ball.piercePoints;
- const remainingSapper = ball.sapperUses < gameState.perks.sapper;
- const extraCombo = gameState.combo - 1;
- if (
- (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) ||
- (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) ||
- (extraCombo && Math.random() > 0.1 / (1 + extraCombo))
- ) {
- const color = remainingSapper
- ? Math.random() > 0.5
- ? "orange"
- : "red"
- : gameState.ballsColor;
+ if (!gameState.brickHP[hitBrick]) {
+ const initialBrickColor = gameState.bricks[hitBrick];
+ ball.brokenSinceBounce++;
- makeParticle(
- gameState,
- ball.x,
- ball.y,
- gameState.perks.pierce_color || remainingPierce
- ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3
- : (Math.random() - 0.5) * gameState.baseSpeed,
- gameState.perks.pierce_color || remainingPierce
- ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3
- : (Math.random() - 0.5) * gameState.baseSpeed,
- color,
- true,
- gameState.coinSize / 2,
- 100,
- );
- }
+ explodeBrick(gameState, hitBrick, ball, false);
+ if (
+ ball.sapperUses < gameState.perks.sapper &&
+ initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks
+ !gameState.bricks[hitBrick]
+ ) {
+ setBrick(gameState, hitBrick, "black");
+ ball.sapperUses++;
+ }
+ } else {
+ schedulGameSound(gameState, "wallBeep", x, 1);
+ makeLight(
+ gameState,
+ brickCenterX(gameState, hitBrick),
+ brickCenterY(gameState, hitBrick),
+ "white",
+ gameState.brickWidth + 2,
+ 50 * gameState.brickHP[hitBrick],
+ );
}
+ }
+
+ if (!isOptionOn("basic")) {
+ const remainingPierce = ball.piercePoints;
+ const remainingSapper = ball.sapperUses < gameState.perks.sapper;
+ const extraCombo = gameState.combo - 1;
+ if (
+ (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) ||
+ (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) ||
+ (extraCombo && Math.random() > 0.1 / (1 + extraCombo))
+ ) {
+ const color = remainingSapper
+ ? Math.random() > 0.5
+ ? "orange"
+ : "red"
+ : gameState.ballsColor;
+
+ makeParticle(
+ gameState,
+ ball.x,
+ ball.y,
+ gameState.perks.pierce_color || remainingPierce
+ ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3
+ : (Math.random() - 0.5) * gameState.baseSpeed,
+ gameState.perks.pierce_color || remainingPierce
+ ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3
+ : (Math.random() - 0.5) * gameState.baseSpeed,
+ color,
+ true,
+ gameState.coinSize / 2,
+ 100,
+ );
+ }
+ }
}
function justLostALife(gameState: GameState, ball: Ball, x: number, y: number) {
- gameState.perks.extra_life -= 1;
- if (gameState.perks.extra_life < 0) {
- gameState.perks.extra_life = 0;
- } else if (gameState.perks.sacrifice) {
- gameState.combo *= gameState.perks.sacrifice
- gameState.bricks.forEach(
- (color, index) => color && explodeBrick(gameState, index, ball, true),
- );
- }
+ gameState.perks.extra_life -= 1;
+ if (gameState.perks.extra_life < 0) {
+ gameState.perks.extra_life = 0;
+ } else if (gameState.perks.sacrifice) {
+ gameState.combo *= gameState.perks.sacrifice;
+ gameState.bricks.forEach(
+ (color, index) => color && explodeBrick(gameState, index, ball, true),
+ );
+ }
- schedulGameSound(gameState, "lifeLost", ball.x, 1);
+ schedulGameSound(gameState, "lifeLost", ball.x, 1);
- if (!isOptionOn("basic")) {
- for (let i = 0; i < 10; i++)
- makeParticle(
- gameState,
- x,
- y,
- Math.random() * gameState.baseSpeed * 3,
- gameState.baseSpeed * 3,
- "red",
- false,
- gameState.coinSize / 2,
- 150,
- );
- }
+ if (!isOptionOn("basic")) {
+ for (let i = 0; i < 10; i++)
+ makeParticle(
+ gameState,
+ x,
+ y,
+ Math.random() * gameState.baseSpeed * 3,
+ gameState.baseSpeed * 3,
+ "red",
+ false,
+ gameState.coinSize / 2,
+ 150,
+ );
+ }
}
function makeCoin(
- gameState: GameState,
- x: number,
- y: number,
- vx: number,
- vy: number,
- color = "gold",
- points = 1,
+ gameState: GameState,
+ x: number,
+ y: number,
+ vx: number,
+ vy: number,
+ color = "gold",
+ points = 1,
) {
- let weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01)
- weight *= 5 / (5 + gameState.perks.etherealcoins)
+ let weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01);
+ weight *= 5 / (5 + gameState.perks.etherealcoins);
- append(gameState.coins, (p: Partial) => {
- p.x = x;
- p.y = y;
- p.collidedLastFrame = true;
- p.size = gameState.coinSize;
- p.previousX = x;
- p.previousY = y;
- p.vx = vx;
- p.vy = vy;
- // p.sx = 0;
- // p.sy = 0;
- p.color = color;
- p.a = Math.random() * Math.PI * 2;
- p.sa = Math.random() - 0.5;
- p.points = points;
- p.weight = weight;
- p.metamorphosisPoints = gameState.perks.metamorphosis
- });
+ append(gameState.coins, (p: Partial) => {
+ p.x = x;
+ p.y = y;
+ p.collidedLastFrame = true;
+ p.size = gameState.coinSize;
+ p.previousX = x;
+ p.previousY = y;
+ p.vx = vx;
+ p.vy = vy;
+ // p.sx = 0;
+ // p.sy = 0;
+ p.color = color;
+ p.a = Math.random() * Math.PI * 2;
+ p.sa = Math.random() - 0.5;
+ p.points = points;
+ p.weight = weight;
+ p.metamorphosisPoints = gameState.perks.metamorphosis;
+ });
}
function makeParticle(
- gameState: GameState,
- x: number,
- y: number,
- vx: number,
- vy: number,
- color: colorString,
- ethereal = false,
- size = 8,
- duration = 150,
+ gameState: GameState,
+ x: number,
+ y: number,
+ vx: number,
+ vy: number,
+ color: colorString,
+ ethereal = false,
+ size = 8,
+ duration = 150,
) {
- append(gameState.particles, (p: Partial) => {
- p.time = gameState.levelTime;
- p.x = x;
- p.y = y;
- p.vx = vx;
- p.vy = vy;
- p.color = color;
- p.size = size;
- p.duration = duration;
- p.ethereal = ethereal;
- });
+ append(gameState.particles, (p: Partial) => {
+ p.time = gameState.levelTime;
+ p.x = x;
+ p.y = y;
+ p.vx = vx;
+ p.vy = vy;
+ p.color = color;
+ p.size = size;
+ p.duration = duration;
+ p.ethereal = ethereal;
+ });
}
function makeText(
- gameState: GameState,
- x: number,
- y: number,
- color: colorString,
- text: string,
- size = 20,
- duration = 500,
+ gameState: GameState,
+ x: number,
+ y: number,
+ color: colorString,
+ text: string,
+ size = 20,
+ duration = 500,
) {
- append(gameState.texts, (p: Partial) => {
- p.time = gameState.levelTime;
- p.x = x;
- p.y = y;
- p.color = color;
- p.size = size;
- p.duration = clamp(duration, 400, 2000);
- p.text = text;
- });
+ append(gameState.texts, (p: Partial) => {
+ p.time = gameState.levelTime;
+ p.x = x;
+ p.y = y;
+ p.color = color;
+ p.size = size;
+ p.duration = clamp(duration, 400, 2000);
+ p.text = text;
+ });
}
function makeLight(
- gameState: GameState,
- x: number,
- y: number,
- color: colorString,
- size = 8,
- duration = 150,
+ gameState: GameState,
+ x: number,
+ y: number,
+ color: colorString,
+ size = 8,
+ duration = 150,
) {
- append(gameState.lights, (p: Partial) => {
- p.time = gameState.levelTime;
- p.x = x;
- p.y = y;
- p.color = color;
- p.size = size;
- p.duration = duration;
- });
+ append(gameState.lights, (p: Partial) => {
+ p.time = gameState.levelTime;
+ p.x = x;
+ p.y = y;
+ p.color = color;
+ p.size = size;
+ p.duration = duration;
+ });
}
export function append(
- where: ReusableArray,
- makeItem: (match: Partial) => void,
+ where: ReusableArray,
+ makeItem: (match: Partial) => void,
) {
- while (
- where.list[where.indexMin] &&
- !where.list[where.indexMin].destroyed &&
- where.indexMin < where.list.length
- ) {
- where.indexMin++;
- }
- if (where.indexMin < where.list.length) {
- where.list[where.indexMin].destroyed = false;
- makeItem(where.list[where.indexMin]);
- where.indexMin++;
- } else {
- const p = {destroyed: false};
- makeItem(p);
- where.list.push(p);
- }
- where.total++;
+ while (
+ where.list[where.indexMin] &&
+ !where.list[where.indexMin].destroyed &&
+ where.indexMin < where.list.length
+ ) {
+ where.indexMin++;
+ }
+ if (where.indexMin < where.list.length) {
+ where.list[where.indexMin].destroyed = false;
+ makeItem(where.list[where.indexMin]);
+ where.indexMin++;
+ } else {
+ const p = { destroyed: false };
+ makeItem(p);
+ where.list.push(p);
+ }
+ where.total++;
}
export function destroy(where: ReusableArray, index: number) {
- if (where.list[index].destroyed) return;
- where.list[index].destroyed = true;
- where.indexMin = Math.min(where.indexMin, index);
- where.total--;
+ if (where.list[index].destroyed) return;
+ where.list[index].destroyed = true;
+ where.indexMin = Math.min(where.indexMin, index);
+ where.total--;
}
export function liveCount(where: ReusableArray) {
- return where.total;
+ return where.total;
}
export function empty(where: ReusableArray) {
- let destroyed=0
- where.total = 0;
- where.indexMin = 0;
- where.list.forEach((i) => {
- if(!i.destroyed) {
- i.destroyed = true
- destroyed++
- }
- });
- return destroyed
+ let destroyed = 0;
+ where.total = 0;
+ where.indexMin = 0;
+ where.list.forEach((i) => {
+ if (!i.destroyed) {
+ i.destroyed = true;
+ destroyed++;
+ }
+ });
+ return destroyed;
}
export function forEachLiveOne(
- where: ReusableArray,
- cb: (t: T, index: number) => void,
+ where: ReusableArray,
+ cb: (t: T, index: number) => void,
) {
- where.list.forEach((item: T, index: number) => {
- if (item && !item.destroyed) {
- cb(item, index);
- }
- });
+ where.list.forEach((item: T, index: number) => {
+ if (item && !item.destroyed) {
+ cb(item, index);
+ }
+ });
}
diff --git a/src/game_utils.ts b/src/game_utils.ts
index 6fec614..9fc5114 100644
--- a/src/game_utils.ts
+++ b/src/game_utils.ts
@@ -1,6 +1,6 @@
-import {Ball, GameState, PerkId, PerksMap} from "./types";
-import {icons, upgrades} from "./loadGameData";
-import {t} from "./i18n/i18n";
+import { Ball, GameState, PerkId, PerksMap } from "./types";
+import { icons, upgrades } from "./loadGameData";
+import { t } from "./i18n/i18n";
export function getMajorityValue(arr: string[]): string {
const count: { [k: string]: number } = {};
@@ -14,10 +14,8 @@ export function sample(arr: T[]): T {
return arr[Math.floor(arr.length * Math.random())];
}
-export function sampleN(arr: T[],n:number): T[] {
-
- return [...arr].sort(()=>Math.random()-0.5)
- .slice(0,n)
+export function sampleN(arr: T[], n: number): T[] {
+ return [...arr].sort(() => Math.random() - 0.5).slice(0, n);
}
export function sumOfValues(obj: { [key: string]: number } | undefined | null) {
@@ -55,7 +53,10 @@ export function getRowColIndex(gameState: GameState, row: number, col: number) {
export function getPossibleUpgrades(gameState: GameState) {
return upgrades
- .filter((u) => gameState.totalScoreAtRunStart >= u.threshold || gameState.loop>0)
+ .filter(
+ (u) =>
+ gameState.totalScoreAtRunStart >= u.threshold || gameState.loop > 0,
+ )
.filter((u) => !u?.requires || gameState.perks[u?.requires]);
}
@@ -186,4 +187,3 @@ export function countBricksBelow(gameState: GameState, index: number) {
}
return count;
}
-
diff --git a/src/newGameState.ts b/src/newGameState.ts
index f3ce899..e8cc458 100644
--- a/src/newGameState.ts
+++ b/src/newGameState.ts
@@ -1,4 +1,4 @@
-import { GameState, RunParams } from "./types";
+import { GameState, RunParams } from "./types";
import { getTotalScore } from "./settings";
import { allLevels, upgrades } from "./loadGameData";
import {
@@ -129,5 +129,3 @@ export function newGameState(params: RunParams): GameState {
}
return gameState;
}
-
-
diff --git a/src/premium.ts b/src/premium.ts
index 5aa7ac8..449d253 100644
--- a/src/premium.ts
+++ b/src/premium.ts
@@ -115,7 +115,6 @@ export function premiumMenuEntry(gameState: GameState) {
text = t("premium.per_hours", args);
help = t("premium.per_hours_help", args);
}
-
}
} catch (e) {
console.warn(e);
diff --git a/src/pure_functions.ts b/src/pure_functions.ts
index 2794f93..90832f3 100644
--- a/src/pure_functions.ts
+++ b/src/pure_functions.ts
@@ -1,7 +1,7 @@
export function clamp(value: number, min: number, max: number) {
- return Math.max(min, Math.min(value, max));
+ return Math.max(min, Math.min(value, max));
}
export function comboKeepingRate(level: number) {
- return clamp(1 - 1 / (1 + level) * 1.5, 0, 1)
-}
\ No newline at end of file
+ return clamp(1 - (1 / (1 + level)) * 1.5, 0, 1);
+}
diff --git a/src/render.ts b/src/render.ts
index dc6582b..f6ddc20 100644
--- a/src/render.ts
+++ b/src/render.ts
@@ -1,1000 +1,1020 @@
-import {baseCombo, forEachLiveOne, liveCount,} from "./gameStateMutators";
+import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators";
import {
- brickCenterX,
- brickCenterY,
- countBricksAbove,
- countBricksBelow,
- currentLevelInfo,
- isTelekinesisActive,
- isYoyoActive,
- max_levels,
+ brickCenterX,
+ brickCenterY,
+ countBricksAbove,
+ countBricksBelow,
+ currentLevelInfo,
+ isTelekinesisActive,
+ isYoyoActive,
+ max_levels,
} from "./game_utils";
-import {colorString, GameState} from "./types";
-import {t} from "./i18n/i18n";
-import {gameState, lastMeasuredFPS} from "./game";
-import {isOptionOn} from "./options";
+import { colorString, GameState } from "./types";
+import { t } from "./i18n/i18n";
+import { gameState, lastMeasuredFPS } from "./game";
+import { isOptionOn } from "./options";
export const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
export const ctx = gameCanvas.getContext("2d", {
- alpha: false,
+ alpha: false,
}) as CanvasRenderingContext2D;
export const bombSVG = document.createElement("img");
bombSVG.src =
- "data:image/svg+xml;base64," +
- btoa(`
+ "data:image/svg+xml;base64," +
+ btoa(`
`);
-bombSVG.onload = () => gameState.needsRender = true
+bombSVG.onload = () => (gameState.needsRender = true);
export const background = document.createElement("img");
export const backgroundCanvas = document.createElement("canvas");
export function render(gameState: GameState) {
- const level = currentLevelInfo(gameState);
+ const level = currentLevelInfo(gameState);
- const hasCombo = gameState.combo > baseCombo(gameState);
- const {width, height} = gameCanvas;
- if (!width || !height) return;
+ const hasCombo = gameState.combo > baseCombo(gameState);
+ const { width, height } = gameCanvas;
+ if (!width || !height) return;
- if (gameState.currentLevel || gameState.levelTime) {
- menuLabel.innerText = gameState.loop
- ? t("play.current_lvl_loop", {
- level: gameState.currentLevel + 1,
- max: max_levels(gameState),
- loop: gameState.loop,
- })
- : t("play.current_lvl", {
- level: gameState.currentLevel + 1,
- max: max_levels(gameState),
- });
- } else {
- menuLabel.innerText = t("play.menu_label");
- }
+ if (gameState.currentLevel || gameState.levelTime) {
+ menuLabel.innerText = gameState.loop
+ ? t("play.current_lvl_loop", {
+ level: gameState.currentLevel + 1,
+ max: max_levels(gameState),
+ loop: gameState.loop,
+ })
+ : t("play.current_lvl", {
+ level: gameState.currentLevel + 1,
+ max: max_levels(gameState),
+ });
+ } else {
+ menuLabel.innerText = t("play.menu_label");
+ }
- const catchRate = gameState.levelSpawnedCoins ?
- (gameState.levelSpawnedCoins - gameState.levelLostCoins)/gameState.levelSpawnedCoins :1
+ const catchRate = gameState.levelSpawnedCoins
+ ? (gameState.levelSpawnedCoins - gameState.levelLostCoins) /
+ gameState.levelSpawnedCoins
+ : 1;
- scoreDisplay.innerHTML=
- (isOptionOn("show_fps") ? `
-
+ scoreDisplay.innerHTML =
+ (isOptionOn("show_fps")
+ ? `
+
${lastMeasuredFPS} FPS
/
- `:'')+
-
-
-
- (isOptionOn('show_stats') ? `
-
- ${Math.floor(catchRate*100)}%
+ `
+ : "") +
+ (isOptionOn("show_stats")
+ ? `
+ 0.9 && "good") || ""}">
+ ${Math.floor(catchRate * 100)}%
/
-
+
${gameState.levelWallBounces} B
/
-
- ${Math.ceil(gameState.levelTime/1000)}s
+
+ ${Math.ceil(gameState.levelTime / 1000)}s
/
-
+
${gameState.levelMisses} M
/
- `: '' )+ `$${gameState.score}`;
+ `
+ : "") +
+ `$${gameState.score}`;
+ scoreDisplay.className =
+ gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : "";
- scoreDisplay.className =
- gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : "";
-
- // Clear
- if (!isOptionOn("basic") && !level.color && level.svg) {
- // Without this the light trails everything
- ctx.globalCompositeOperation = "source-over";
- ctx.globalAlpha = 1;
- ctx.fillStyle = "#000";
- ctx.fillRect(0, 0, width, height);
-
- ctx.globalCompositeOperation = "screen";
- ctx.globalAlpha = 0.6;
-
- forEachLiveOne(gameState.coins, (coin) => {
- drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
- });
- gameState.balls.forEach((ball) => {
- drawFuzzyBall(
- ctx,
- gameState.ballsColor,
- gameState.ballSize * 2,
- ball.x,
- ball.y,
- );
- });
- ctx.globalAlpha = 0.5;
- gameState.bricks.forEach((color, index) => {
- if (!color) return;
- const x = brickCenterX(gameState, index),
- y = brickCenterY(gameState, index);
- drawFuzzyBall(
- ctx,
- color == "black" ? "#666" : color,
- gameState.brickWidth,
- x,
- y,
- );
- });
- ctx.globalAlpha = 1;
-
- forEachLiveOne(gameState.particles, (flash) => {
- const {x, y, time, color, size, duration} = flash;
- const elapsed = gameState.levelTime - time;
- ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
- drawFuzzyBall(ctx, color, size * 3, x, y);
- });
-
- // Decides how brights the bg black parts can get
- ctx.globalAlpha = 0.2;
- ctx.globalCompositeOperation = "multiply";
- ctx.fillStyle = "black";
- ctx.fillRect(0, 0, width, height);
- // Decides how dark the background black parts are when lit (1=black)
- ctx.globalAlpha = 0.8;
- ctx.globalCompositeOperation = "multiply";
- if (level.svg && background.width && background.complete) {
- if (backgroundCanvas.title !== level.name) {
- backgroundCanvas.title = level.name;
- backgroundCanvas.width = gameState.canvasWidth;
- backgroundCanvas.height = gameState.canvasHeight;
- const bgctx = backgroundCanvas.getContext(
- "2d",
- ) as CanvasRenderingContext2D;
- bgctx.fillStyle = level.color || "#000";
- bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight);
- if (gameState.perks.clairvoyant >= 3) {
- const pageSource = document.body.innerHTML.replace(/\s+/gi, '')
- const lineWidth = Math.ceil(gameState.canvasWidth / 15)
- const lines = Math.ceil(gameState.canvasHeight / 20)
- const chars = lineWidth * lines
- let start = Math.ceil(Math.random() * (pageSource.length - chars))
- for (let i = 0; i < lines; i++) {
- bgctx.fillStyle = 'white'
- bgctx.font = '20px Courier'
- bgctx.fillText(pageSource.slice(
- start + i * lineWidth,
- start + (i + 1) * lineWidth),
- 0,
- i * 20,
- gameState.canvasWidth
- )
- }
- } else {
-
- const pattern = ctx.createPattern(background, "repeat");
- if (pattern) {
- bgctx.fillStyle = pattern;
- bgctx.fillRect(0, 0, width, height);
- }
- }
- }
-
- ctx.drawImage(backgroundCanvas, 0, 0);
- } else {
- // Background not loaded yes
- ctx.fillStyle = "#000";
- ctx.fillRect(0, 0, width, height);
- }
- } else {
- ctx.globalAlpha = 1;
- ctx.globalCompositeOperation = "source-over";
- ctx.fillStyle = level.color || "#000";
- ctx.fillRect(0, 0, width, height);
- forEachLiveOne(gameState.particles, (flash) => {
- const {x, y, time, color, size, duration} = flash;
- const elapsed = gameState.levelTime - time;
- ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
- drawBall(ctx, color, size, x, y);
- });
- }
-
- ctx.globalAlpha = 1;
+ // Clear
+ if (!isOptionOn("basic") && !level.color && level.svg) {
+ // Without this the light trails everything
ctx.globalCompositeOperation = "source-over";
- const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5;
- const shaked = lastExplosionDelay < 200 && !isOptionOn("basic");
- if (shaked) {
- const amplitude =
- ((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay;
- ctx.translate(
- Math.sin(Date.now()) * amplitude,
- Math.sin(Date.now() + 36) * amplitude,
- );
- }
- if (gameState.perks.bigger_explosions && !isOptionOn("basic") && shaked) {
- gameCanvas.style.filter =
- "brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")";
- } else {
- gameCanvas.style.filter = "";
- }
- // Coins
ctx.globalAlpha = 1;
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, width, height);
+
+ ctx.globalCompositeOperation = "screen";
+ ctx.globalAlpha = 0.6;
+
forEachLiveOne(gameState.coins, (coin) => {
- ctx.globalCompositeOperation = "source-over";
- // ctx.globalCompositeOperation =
- // coin.color === "gold" || level.color ? "source-over" : "screen";
- drawCoin(
- ctx,
- coin.color,
- coin.size,
- coin.x,
- coin.y,
- (hasCombo && gameState.perks.asceticism && "red") ||
- (coin.color==='gold' && 'gold')||
- gameState.puckColor,
- coin.a,
- );
+ drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y);
});
-
- // Black shadow around balls
- if (!isOptionOn("basic")) {
- ctx.globalCompositeOperation = "source-over";
- ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20);
- gameState.balls.forEach((ball) => {
- drawBall(
- ctx,
- level.color || "#000",
- gameState.ballSize * 6,
- ball.x,
- ball.y,
- );
- });
-
- }
-
- ctx.globalCompositeOperation = "source-over";
- renderAllBricks();
-
- ctx.globalCompositeOperation = "screen";
- forEachLiveOne(gameState.lights, (flash) => {
- const {x, y, time, color, size, duration} = flash;
- const elapsed = gameState.levelTime - time;
- ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2) * 0.5;
- drawBrick(ctx, color, x, y, -1, gameState.perks.clairvoyant >= 2);
- });
-
- ctx.globalCompositeOperation = "screen";
- forEachLiveOne(gameState.texts, (flash) => {
- const {x, y, time, color, size, duration} = flash;
- const elapsed = gameState.levelTime - time;
- ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
- ctx.globalCompositeOperation = "source-over";
- drawText(ctx, flash.text, color, size, x, y - elapsed / 10);
- });
-
- forEachLiveOne(gameState.particles, (particle) => {
- const {x, y, time, color, size, duration} = particle;
- const elapsed = gameState.levelTime - time;
- ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
- ctx.globalCompositeOperation = "screen";
- drawBall(ctx, color, size, x, y);
- drawFuzzyBall(ctx, color, size, x, y);
- });
-
- if (gameState.perks.extra_life) {
- ctx.globalAlpha = 1;
- ctx.globalCompositeOperation = "source-over";
- ctx.fillStyle = gameState.puckColor;
- for (let i = 0; i < gameState.perks.extra_life; i++) {
- ctx.fillRect(
- gameState.perks.unbounded ? 0 : gameState.offsetXRoundedDown,
- gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i,
- gameState.perks.unbounded
- ? gameState.canvasWidth
- : gameState.gameZoneWidthRoundedUp,
- 1,
- );
- }
- }
-
- ctx.globalAlpha = 1;
- ctx.globalCompositeOperation = "source-over";
-
gameState.balls.forEach((ball) => {
- const drawingColor = gameState.ballsColor;
-
- // The white border around is to distinguish colored balls from coins/bg
- drawBall(
- ctx,
- drawingColor,
- gameState.ballSize,
- ball.x,
- ball.y,
- gameState.puckColor,
- );
-
- if (isTelekinesisActive(gameState, ball) || isYoyoActive(gameState, ball)) {
- ctx.beginPath();
- ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight);
-
- ctx.strokeStyle = gameState.puckColor;
- ctx.bezierCurveTo(
- gameState.puckPosition,
- gameState.gameZoneHeight,
- gameState.puckPosition,
- ball.y,
- ball.x,
- ball.y,
- );
- ctx.stroke();
-
- ctx.lineWidth = 2;
- ctx.setLineDash(emptyArray);
- }
- if (gameState.perks.clairvoyant && gameState.ballStickToPuck) {
- ctx.strokeStyle = gameState.ballsColor;
- ctx.beginPath();
- ctx.moveTo(ball.x, ball.y);
- ctx.lineTo(ball.x + ball.vx * 10, ball.y + ball.vy * 10);
- ctx.stroke();
- }
+ drawFuzzyBall(
+ ctx,
+ gameState.ballsColor,
+ gameState.ballSize * 2,
+ ball.x,
+ ball.y,
+ );
});
- // The puck
+ ctx.globalAlpha = 0.5;
+ gameState.bricks.forEach((color, index) => {
+ if (!color) return;
+ const x = brickCenterX(gameState, index),
+ y = brickCenterY(gameState, index);
+ drawFuzzyBall(
+ ctx,
+ color == "black" ? "#666" : color,
+ gameState.brickWidth,
+ x,
+ y,
+ );
+ });
+ ctx.globalAlpha = 1;
+
+ forEachLiveOne(gameState.particles, (flash) => {
+ const { x, y, time, color, size, duration } = flash;
+ const elapsed = gameState.levelTime - time;
+ ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
+ drawFuzzyBall(ctx, color, size * 3, x, y);
+ });
+
+ // Decides how brights the bg black parts can get
+ ctx.globalAlpha = 0.2;
+ ctx.globalCompositeOperation = "multiply";
+ ctx.fillStyle = "black";
+ ctx.fillRect(0, 0, width, height);
+ // Decides how dark the background black parts are when lit (1=black)
+ ctx.globalAlpha = 0.8;
+ ctx.globalCompositeOperation = "multiply";
+ if (level.svg && background.width && background.complete) {
+ if (backgroundCanvas.title !== level.name) {
+ backgroundCanvas.title = level.name;
+ backgroundCanvas.width = gameState.canvasWidth;
+ backgroundCanvas.height = gameState.canvasHeight;
+ const bgctx = backgroundCanvas.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D;
+ bgctx.fillStyle = level.color || "#000";
+ bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight);
+ if (gameState.perks.clairvoyant >= 3) {
+ const pageSource = document.body.innerHTML.replace(/\s+/gi, "");
+ const lineWidth = Math.ceil(gameState.canvasWidth / 15);
+ const lines = Math.ceil(gameState.canvasHeight / 20);
+ const chars = lineWidth * lines;
+ let start = Math.ceil(Math.random() * (pageSource.length - chars));
+ for (let i = 0; i < lines; i++) {
+ bgctx.fillStyle = "white";
+ bgctx.font = "20px Courier";
+ bgctx.fillText(
+ pageSource.slice(
+ start + i * lineWidth,
+ start + (i + 1) * lineWidth,
+ ),
+ 0,
+ i * 20,
+ gameState.canvasWidth,
+ );
+ }
+ } else {
+ const pattern = ctx.createPattern(background, "repeat");
+ if (pattern) {
+ bgctx.fillStyle = pattern;
+ bgctx.fillRect(0, 0, width, height);
+ }
+ }
+ }
+
+ ctx.drawImage(backgroundCanvas, 0, 0);
+ } else {
+ // Background not loaded yes
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, width, height);
+ }
+ } else {
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
+ ctx.fillStyle = level.color || "#000";
+ ctx.fillRect(0, 0, width, height);
+ forEachLiveOne(gameState.particles, (flash) => {
+ const { x, y, time, color, size, duration } = flash;
+ const elapsed = gameState.levelTime - time;
+ ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2);
+ drawBall(ctx, color, size, x, y);
+ });
+ }
- drawPuck(
- ctx,
- gameState.puckColor,
- gameState.puckWidth,
- gameState.puckHeight,
- 0,
- gameState.perks.concave_puck,
- gameState.perks.streak_shots && hasCombo ? getDashOffset(gameState) : -1,
+ ctx.globalAlpha = 1;
+ ctx.globalCompositeOperation = "source-over";
+ const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5;
+ const shaked = lastExplosionDelay < 200 && !isOptionOn("basic");
+ if (shaked) {
+ const amplitude =
+ ((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay;
+ ctx.translate(
+ Math.sin(Date.now()) * amplitude,
+ Math.sin(Date.now() + 36) * amplitude,
);
-
- if (gameState.combo > 1) {
- ctx.globalCompositeOperation = "source-over";
- const comboText = "x " + gameState.combo;
- const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8;
- const totalWidth = comboTextWidth + gameState.coinSize * 2;
- const left = gameState.puckPosition - totalWidth / 2;
- if (totalWidth < gameState.puckWidth) {
- drawCoin(
- ctx,
- "gold",
- gameState.coinSize,
- left + gameState.coinSize / 2,
- gameState.gameZoneHeight - gameState.puckHeight / 2,
- gameState.puckColor,
- 0,
- );
- drawText(
- ctx,
- comboText,
- "#000",
- gameState.puckHeight,
- left + gameState.coinSize * 1.5,
- gameState.gameZoneHeight - gameState.puckHeight / 2,
- true,
- );
- } else {
- drawText(
- ctx,
- comboTextWidth > gameState.puckWidth
- ? gameState.combo.toString()
- : comboText,
- "#000",
- comboTextWidth > gameState.puckWidth ? 12 : 20,
- gameState.puckPosition,
- gameState.gameZoneHeight - gameState.puckHeight / 2,
- false,
- );
- }
- }
- // Borders
-
+ }
+ if (gameState.perks.bigger_explosions && !isOptionOn("basic") && shaked) {
+ gameCanvas.style.filter =
+ "brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")";
+ } else {
+ gameCanvas.style.filter = "";
+ }
+ // Coins
+ ctx.globalAlpha = 1;
+ forEachLiveOne(gameState.coins, (coin) => {
ctx.globalCompositeOperation = "source-over";
- ctx.globalAlpha = gameState.perks.unbounded ? 0.1 : 1;
+ // ctx.globalCompositeOperation =
+ // coin.color === "gold" || level.color ? "source-over" : "screen";
+ drawCoin(
+ ctx,
+ coin.color,
+ coin.size,
+ coin.x,
+ coin.y,
+ (hasCombo && gameState.perks.asceticism && "red") ||
+ (coin.color === "gold" && "gold") ||
+ gameState.puckColor,
+ coin.a,
+ );
+ });
- if (gameState.offsetXRoundedDown) {
- // draw outside of gaming area to avoid capturing borders in recordings
- ctx.fillStyle =
- hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor;
+ // Black shadow around balls
+ if (!isOptionOn("basic")) {
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20);
+ gameState.balls.forEach((ball) => {
+ drawBall(
+ ctx,
+ level.color || "#000",
+ gameState.ballSize * 6,
+ ball.x,
+ ball.y,
+ );
+ });
+ }
- drawStraightLine(
- ctx,
- gameState,
- (hasCombo &&
- gameState.perks.left_is_lava &&
- !gameState.perks.unbounded &&
- "red") ||
- "white",
- gameState.offsetX - 1,
- 0,
- gameState.offsetX - 1,
- height,
- gameState.perks.unbounded ? 0.1 : 1,
- );
+ ctx.globalCompositeOperation = "source-over";
+ renderAllBricks();
- drawStraightLine(
- ctx,
- gameState,
- (hasCombo &&
- gameState.perks.right_is_lava &&
- !gameState.perks.unbounded &&
- "red") ||
- "white",
- width - gameState.offsetX + 1,
- 0,
- width - gameState.offsetX + 1,
- height,
- gameState.perks.unbounded ? 0.1 : 1,
- );
+ ctx.globalCompositeOperation = "screen";
+ forEachLiveOne(gameState.lights, (flash) => {
+ const { x, y, time, color, size, duration } = flash;
+ const elapsed = gameState.levelTime - time;
+ ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2) * 0.5;
+ drawBrick(ctx, color, x, y, -1, gameState.perks.clairvoyant >= 2);
+ });
+
+ ctx.globalCompositeOperation = "screen";
+ forEachLiveOne(gameState.texts, (flash) => {
+ const { x, y, time, color, size, duration } = flash;
+ const elapsed = gameState.levelTime - time;
+ ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
+ ctx.globalCompositeOperation = "source-over";
+ drawText(ctx, flash.text, color, size, x, y - elapsed / 10);
+ });
+
+ forEachLiveOne(gameState.particles, (particle) => {
+ const { x, y, time, color, size, duration } = particle;
+ const elapsed = gameState.levelTime - time;
+ ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2));
+ ctx.globalCompositeOperation = "screen";
+ drawBall(ctx, color, size, x, y);
+ drawFuzzyBall(ctx, color, size, x, y);
+ });
+
+ if (gameState.perks.extra_life) {
+ ctx.globalAlpha = 1;
+ ctx.globalCompositeOperation = "source-over";
+ ctx.fillStyle = gameState.puckColor;
+ for (let i = 0; i < gameState.perks.extra_life; i++) {
+ ctx.fillRect(
+ gameState.perks.unbounded ? 0 : gameState.offsetXRoundedDown,
+ gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i,
+ gameState.perks.unbounded
+ ? gameState.canvasWidth
+ : gameState.gameZoneWidthRoundedUp,
+ 1,
+ );
+ }
+ }
+
+ ctx.globalAlpha = 1;
+ ctx.globalCompositeOperation = "source-over";
+
+ gameState.balls.forEach((ball) => {
+ const drawingColor = gameState.ballsColor;
+
+ // The white border around is to distinguish colored balls from coins/bg
+ drawBall(
+ ctx,
+ drawingColor,
+ gameState.ballSize,
+ ball.x,
+ ball.y,
+ gameState.puckColor,
+ );
+
+ if (isTelekinesisActive(gameState, ball) || isYoyoActive(gameState, ball)) {
+ ctx.beginPath();
+ ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight);
+
+ ctx.strokeStyle = gameState.puckColor;
+ ctx.bezierCurveTo(
+ gameState.puckPosition,
+ gameState.gameZoneHeight,
+ gameState.puckPosition,
+ ball.y,
+ ball.x,
+ ball.y,
+ );
+ ctx.stroke();
+
+ ctx.lineWidth = 2;
+ ctx.setLineDash(emptyArray);
+ }
+ if (gameState.perks.clairvoyant && gameState.ballStickToPuck) {
+ ctx.strokeStyle = gameState.ballsColor;
+ ctx.beginPath();
+ ctx.moveTo(ball.x, ball.y);
+ ctx.lineTo(ball.x + ball.vx * 10, ball.y + ball.vy * 10);
+ ctx.stroke();
+ }
+ });
+ // The puck
+ ctx.globalAlpha = 1;
+ ctx.globalCompositeOperation = "source-over";
+
+ drawPuck(
+ ctx,
+ gameState.puckColor,
+ gameState.puckWidth,
+ gameState.puckHeight,
+ 0,
+ gameState.perks.concave_puck,
+ gameState.perks.streak_shots && hasCombo ? getDashOffset(gameState) : -1,
+ );
+
+ if (gameState.combo > 1) {
+ ctx.globalCompositeOperation = "source-over";
+ const comboText = "x " + gameState.combo;
+ const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8;
+ const totalWidth = comboTextWidth + gameState.coinSize * 2;
+ const left = gameState.puckPosition - totalWidth / 2;
+ if (totalWidth < gameState.puckWidth) {
+ drawCoin(
+ ctx,
+ "gold",
+ gameState.coinSize,
+ left + gameState.coinSize / 2,
+ gameState.gameZoneHeight - gameState.puckHeight / 2,
+ gameState.puckColor,
+ 0,
+ );
+ drawText(
+ ctx,
+ comboText,
+ "#000",
+ gameState.puckHeight,
+ left + gameState.coinSize * 1.5,
+ gameState.gameZoneHeight - gameState.puckHeight / 2,
+ true,
+ );
} else {
- ctx.fillStyle = "red";
-
- drawStraightLine(
- ctx,
- gameState,
- (hasCombo &&
- gameState.perks.left_is_lava &&
- !gameState.perks.unbounded &&
- "red") ||
- "",
- 0,
- 0,
- 0,
- height,
- 1,
- );
-
- drawStraightLine(
- ctx,
- gameState,
- (hasCombo &&
- gameState.perks.right_is_lava &&
- !gameState.perks.unbounded &&
- "red") ||
- "",
- width - 1,
- 0,
- width - 1,
- height,
- 1,
- );
- }
-
- ctx.globalAlpha = gameState.perks.unbounded > 1 ? 0.1 : 1
- drawStraightLine(
+ drawText(
ctx,
- gameState,
- (hasCombo && gameState.perks.top_is_lava && "red") || "",
- gameState.offsetXRoundedDown,
- 1,
- width - gameState.offsetXRoundedDown,
- 1,
- 1,
+ comboTextWidth > gameState.puckWidth
+ ? gameState.combo.toString()
+ : comboText,
+ "#000",
+ comboTextWidth > gameState.puckWidth ? 12 : 20,
+ gameState.puckPosition,
+ gameState.gameZoneHeight - gameState.puckHeight / 2,
+ false,
+ );
+ }
+ }
+ // Borders
+
+ ctx.globalCompositeOperation = "source-over";
+ ctx.globalAlpha = gameState.perks.unbounded ? 0.1 : 1;
+
+ if (gameState.offsetXRoundedDown) {
+ // draw outside of gaming area to avoid capturing borders in recordings
+ ctx.fillStyle =
+ hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor;
+
+ drawStraightLine(
+ ctx,
+ gameState,
+ (hasCombo &&
+ gameState.perks.left_is_lava &&
+ !gameState.perks.unbounded &&
+ "red") ||
+ "white",
+ gameState.offsetX - 1,
+ 0,
+ gameState.offsetX - 1,
+ height,
+ gameState.perks.unbounded ? 0.1 : 1,
);
- ctx.globalAlpha = 1
drawStraightLine(
- ctx,
- gameState,
- (hasCombo && gameState.perks.compound_interest && "red") ||
- (isOptionOn("mobile-mode") && "white") ||
+ ctx,
+ gameState,
+ (hasCombo &&
+ gameState.perks.right_is_lava &&
+ !gameState.perks.unbounded &&
+ "red") ||
+ "white",
+ width - gameState.offsetX + 1,
+ 0,
+ width - gameState.offsetX + 1,
+ height,
+ gameState.perks.unbounded ? 0.1 : 1,
+ );
+ } else {
+ ctx.fillStyle = "red";
+
+ drawStraightLine(
+ ctx,
+ gameState,
+ (hasCombo &&
+ gameState.perks.left_is_lava &&
+ !gameState.perks.unbounded &&
+ "red") ||
"",
- gameState.offsetXRoundedDown,
- gameState.gameZoneHeight,
- width - gameState.offsetXRoundedDown,
- gameState.gameZoneHeight,
- 1,
+ 0,
+ 0,
+ 0,
+ height,
+ 1,
);
- if (isOptionOn("mobile-mode") && !gameState.running) {
- drawText(
- ctx,
- t("play.mobile_press_to_play"),
- gameState.puckColor,
- gameState.puckHeight,
- gameState.canvasWidth / 2,
- gameState.gameZoneHeight +
- (gameState.canvasHeight - gameState.gameZoneHeight) / 2,
- );
- }
+ drawStraightLine(
+ ctx,
+ gameState,
+ (hasCombo &&
+ gameState.perks.right_is_lava &&
+ !gameState.perks.unbounded &&
+ "red") ||
+ "",
+ width - 1,
+ 0,
+ width - 1,
+ height,
+ 1,
+ );
+ }
+ ctx.globalAlpha = gameState.perks.unbounded > 1 ? 0.1 : 1;
+ drawStraightLine(
+ ctx,
+ gameState,
+ (hasCombo && gameState.perks.top_is_lava && "red") || "",
+ gameState.offsetXRoundedDown,
+ 1,
+ width - gameState.offsetXRoundedDown,
+ 1,
+ 1,
+ );
- if (shaked) {
- ctx.resetTransform();
- }
+ ctx.globalAlpha = 1;
+ drawStraightLine(
+ ctx,
+ gameState,
+ (hasCombo && gameState.perks.compound_interest && "red") ||
+ (isOptionOn("mobile-mode") && "white") ||
+ "",
+ gameState.offsetXRoundedDown,
+ gameState.gameZoneHeight,
+ width - gameState.offsetXRoundedDown,
+ gameState.gameZoneHeight,
+ 1,
+ );
+
+ if (isOptionOn("mobile-mode") && !gameState.running) {
+ drawText(
+ ctx,
+ t("play.mobile_press_to_play"),
+ gameState.puckColor,
+ gameState.puckHeight,
+ gameState.canvasWidth / 2,
+ gameState.gameZoneHeight +
+ (gameState.canvasHeight - gameState.gameZoneHeight) / 2,
+ );
+ }
+
+ if (shaked) {
+ ctx.resetTransform();
+ }
}
function drawStraightLine(
- ctx: CanvasRenderingContext2D,
- gameState: GameState,
- mode: "white" | "" | "red",
- x1,
- y1,
- x2,
- y2,
- alpha = 1,
+ ctx: CanvasRenderingContext2D,
+ gameState: GameState,
+ mode: "white" | "" | "red",
+ x1,
+ y1,
+ x2,
+ y2,
+ alpha = 1,
) {
- ctx.globalAlpha = alpha;
- if (!mode) return;
- if (mode == "red") {
- ctx.strokeStyle = "red";
- ctx.lineDashOffset = getDashOffset(gameState);
- ctx.lineWidth = 2;
- ctx.setLineDash(redBorderDash);
- } else {
- ctx.strokeStyle = "white";
- ctx.lineWidth = 1;
- }
- ctx.beginPath();
- ctx.moveTo(x1, y1);
- ctx.lineTo(x2, y2);
- ctx.stroke();
- if (mode == "red") {
- ctx.setLineDash(emptyArray);
- ctx.lineWidth = 1;
- }
- ctx.globalAlpha = 1;
+ ctx.globalAlpha = alpha;
+ if (!mode) return;
+ if (mode == "red") {
+ ctx.strokeStyle = "red";
+ ctx.lineDashOffset = getDashOffset(gameState);
+ ctx.lineWidth = 2;
+ ctx.setLineDash(redBorderDash);
+ } else {
+ ctx.strokeStyle = "white";
+ ctx.lineWidth = 1;
+ }
+ ctx.beginPath();
+ ctx.moveTo(x1, y1);
+ ctx.lineTo(x2, y2);
+ ctx.stroke();
+ if (mode == "red") {
+ ctx.setLineDash(emptyArray);
+ ctx.lineWidth = 1;
+ }
+ ctx.globalAlpha = 1;
}
let cachedBricksRender = document.createElement("canvas");
let cachedBricksRenderKey = "";
export function renderAllBricks() {
- ctx.globalAlpha = 1;
+ ctx.globalAlpha = 1;
- const hasCombo = gameState.combo > baseCombo(gameState);
- const redBorderOnBricksWithWrongColor =
- hasCombo && gameState.perks.picky_eater && !isOptionOn("basic");
+ const hasCombo = gameState.combo > baseCombo(gameState);
+ const redBorderOnBricksWithWrongColor =
+ hasCombo && gameState.perks.picky_eater && !isOptionOn("basic");
- const redColorOnAllBricks = !!(
- gameState.lastPuckMove &&
- gameState.perks.passive_income &&
- hasCombo &&
- gameState.lastPuckMove >
- gameState.levelTime - 250 * gameState.perks.passive_income
- );
+ const redColorOnAllBricks = !!(
+ gameState.lastPuckMove &&
+ gameState.perks.passive_income &&
+ hasCombo &&
+ gameState.lastPuckMove >
+ gameState.levelTime - 250 * gameState.perks.passive_income
+ );
- let offset = getDashOffset(gameState);
- if (
- !(
- redBorderOnBricksWithWrongColor ||
- redColorOnAllBricks ||
- gameState.perks.reach ||
- gameState.perks.zen
- )
- ) {
- offset = 0;
- }
+ let offset = getDashOffset(gameState);
+ if (
+ !(
+ redBorderOnBricksWithWrongColor ||
+ redColorOnAllBricks ||
+ gameState.perks.reach ||
+ gameState.perks.zen
+ )
+ ) {
+ offset = 0;
+ }
- const clairVoyance =
- gameState.perks.clairvoyant && gameState.brickHP.reduce((a, b) => a + b, 0);
+ const clairVoyance =
+ gameState.perks.clairvoyant && gameState.brickHP.reduce((a, b) => a + b, 0);
- const newKey =
- gameState.gameZoneWidth +
- "_" +
- gameState.bricks.join("_") +
- bombSVG.complete +
- "_" +
- redBorderOnBricksWithWrongColor +
- "_" +
- redColorOnAllBricks +
- "_" +
- gameState.ballsColor +
- "_" +
- gameState.perks.pierce_color +
- "_" +
- clairVoyance +
- "_" +
- offset;
+ const newKey =
+ gameState.gameZoneWidth +
+ "_" +
+ gameState.bricks.join("_") +
+ bombSVG.complete +
+ "_" +
+ redBorderOnBricksWithWrongColor +
+ "_" +
+ redColorOnAllBricks +
+ "_" +
+ gameState.ballsColor +
+ "_" +
+ gameState.perks.pierce_color +
+ "_" +
+ clairVoyance +
+ "_" +
+ offset;
- if (newKey !== cachedBricksRenderKey) {
- cachedBricksRenderKey = newKey;
+ if (newKey !== cachedBricksRenderKey) {
+ cachedBricksRenderKey = newKey;
- cachedBricksRender.width = gameState.gameZoneWidth;
- cachedBricksRender.height = gameState.gameZoneWidth + 1;
- const canctx = cachedBricksRender.getContext(
- "2d",
- ) as CanvasRenderingContext2D;
- canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth);
- canctx.resetTransform();
- canctx.translate(-gameState.offsetX, 0);
- // Bricks
- gameState.bricks.forEach((color, index) => {
- const x = brickCenterX(gameState, index),
- y = brickCenterY(gameState, index);
+ cachedBricksRender.width = gameState.gameZoneWidth;
+ cachedBricksRender.height = gameState.gameZoneWidth + 1;
+ const canctx = cachedBricksRender.getContext(
+ "2d",
+ ) as CanvasRenderingContext2D;
+ canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth);
+ canctx.resetTransform();
+ canctx.translate(-gameState.offsetX, 0);
+ // Bricks
+ gameState.bricks.forEach((color, index) => {
+ const x = brickCenterX(gameState, index),
+ y = brickCenterY(gameState, index);
- if (!color) return;
+ if (!color) return;
- let redBecauseOfReach =
- gameState.perks.reach &&
- countBricksAbove(gameState, index) &&
- !countBricksBelow(gameState, index);
+ let redBecauseOfReach =
+ gameState.perks.reach &&
+ countBricksAbove(gameState, index) &&
+ !countBricksBelow(gameState, index);
- let redBorder = (gameState.ballsColor !== color &&
- color !== "black" &&
- redBorderOnBricksWithWrongColor) ||
- (hasCombo && gameState.perks.zen && color === "black") ||
- redBecauseOfReach ||
- redColorOnAllBricks;
+ let redBorder =
+ (gameState.ballsColor !== color &&
+ color !== "black" &&
+ redBorderOnBricksWithWrongColor) ||
+ (hasCombo && gameState.perks.zen && color === "black") ||
+ redBecauseOfReach ||
+ redColorOnAllBricks;
- canctx.globalCompositeOperation = "source-over";
- drawBrick(canctx,
- color, x, y, redBorder ? offset : -1, gameState.perks.clairvoyant >= 2);
- if (gameState.brickHP[index] > 1 && gameState.perks.clairvoyant) {
- canctx.globalCompositeOperation = "destination-out";
- drawText(
- canctx,
- gameState.brickHP[index].toString(),
- "white",
- gameState.puckHeight,
- x,
- y,
- );
- }
+ canctx.globalCompositeOperation = "source-over";
+ drawBrick(
+ canctx,
+ color,
+ x,
+ y,
+ redBorder ? offset : -1,
+ gameState.perks.clairvoyant >= 2,
+ );
+ if (gameState.brickHP[index] > 1 && gameState.perks.clairvoyant) {
+ canctx.globalCompositeOperation = "destination-out";
+ drawText(
+ canctx,
+ gameState.brickHP[index].toString(),
+ "white",
+ gameState.puckHeight,
+ x,
+ y,
+ );
+ }
- if (color === "black") {
- canctx.globalCompositeOperation = "source-over";
- drawIMG(canctx, bombSVG, gameState.brickWidth, x, y);
- }
- });
- }
+ if (color === "black") {
+ canctx.globalCompositeOperation = "source-over";
+ drawIMG(canctx, bombSVG, gameState.brickWidth, x, y);
+ }
+ });
+ }
- ctx.drawImage(cachedBricksRender, gameState.offsetX, 0);
+ ctx.drawImage(cachedBricksRender, gameState.offsetX, 0);
}
let cachedGraphics: { [k: string]: HTMLCanvasElement } = {};
export function drawPuck(
- ctx: CanvasRenderingContext2D,
- color: colorString,
- puckWidth: number,
- puckHeight: number,
- yOffset = 0,
- concave_puck: number,
- redBorderOffset: number,
+ ctx: CanvasRenderingContext2D,
+ color: colorString,
+ puckWidth: number,
+ puckHeight: number,
+ yOffset = 0,
+ concave_puck: number,
+ redBorderOffset: number,
) {
- const key =
- "puck" +
- color +
- "_" +
- puckWidth +
- "_" +
- puckHeight +
- "_" +
- concave_puck +
- "_" +
- redBorderOffset;
+ const key =
+ "puck" +
+ color +
+ "_" +
+ puckWidth +
+ "_" +
+ puckHeight +
+ "_" +
+ concave_puck +
+ "_" +
+ redBorderOffset;
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = puckWidth;
- can.height = puckHeight * 2;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- canctx.fillStyle = color;
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = puckWidth;
+ can.height = puckHeight * 2;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ canctx.fillStyle = color;
- canctx.beginPath();
- canctx.moveTo(0, puckHeight * 2);
+ canctx.beginPath();
+ canctx.moveTo(0, puckHeight * 2);
- if (concave_puck) {
- canctx.lineTo(0, puckHeight * 0.75);
- canctx.bezierCurveTo(
- puckWidth / 2,
- puckHeight * (2 + concave_puck) / 3,
- puckWidth / 2,
- puckHeight * (2 + concave_puck) / 3,
- puckWidth,
- puckHeight * 0.75,
- );
- canctx.lineTo(puckWidth, puckHeight * 2);
- } else {
- canctx.lineTo(0, puckHeight * 1.25);
- canctx.bezierCurveTo(
- 0,
- puckHeight * 0.75,
- puckWidth,
- puckHeight * 0.75,
- puckWidth,
- puckHeight * 1.25,
- );
- canctx.lineTo(puckWidth, puckHeight * 2);
- }
-
- canctx.fill();
-
- if (redBorderOffset !== -1) {
- canctx.strokeStyle = "red";
- canctx.lineWidth = 4;
- canctx.setLineDash(redBorderDash);
- canctx.lineDashOffset = redBorderOffset;
- canctx.stroke();
- }
-
- cachedGraphics[key] = can;
+ if (concave_puck) {
+ canctx.lineTo(0, puckHeight * 0.75);
+ canctx.bezierCurveTo(
+ puckWidth / 2,
+ (puckHeight * (2 + concave_puck)) / 3,
+ puckWidth / 2,
+ (puckHeight * (2 + concave_puck)) / 3,
+ puckWidth,
+ puckHeight * 0.75,
+ );
+ canctx.lineTo(puckWidth, puckHeight * 2);
+ } else {
+ canctx.lineTo(0, puckHeight * 1.25);
+ canctx.bezierCurveTo(
+ 0,
+ puckHeight * 0.75,
+ puckWidth,
+ puckHeight * 0.75,
+ puckWidth,
+ puckHeight * 1.25,
+ );
+ canctx.lineTo(puckWidth, puckHeight * 2);
}
- ctx.drawImage(
- cachedGraphics[key],
- Math.round(gameState.puckPosition - puckWidth / 2),
- gameState.gameZoneHeight - puckHeight * 2 + yOffset,
- );
+ canctx.fill();
+
+ if (redBorderOffset !== -1) {
+ canctx.strokeStyle = "red";
+ canctx.lineWidth = 4;
+ canctx.setLineDash(redBorderDash);
+ canctx.lineDashOffset = redBorderOffset;
+ canctx.stroke();
+ }
+
+ cachedGraphics[key] = can;
+ }
+
+ ctx.drawImage(
+ cachedGraphics[key],
+ Math.round(gameState.puckPosition - puckWidth / 2),
+ gameState.gameZoneHeight - puckHeight * 2 + yOffset,
+ );
}
export function drawBall(
- ctx: CanvasRenderingContext2D,
- color: colorString,
- width: number,
- x: number,
- y: number,
- borderColor = "",
+ ctx: CanvasRenderingContext2D,
+ color: colorString,
+ width: number,
+ x: number,
+ y: number,
+ borderColor = "",
) {
- const key = "ball" + color + "_" + width + "_" + borderColor;
+ const key = "ball" + color + "_" + width + "_" + borderColor;
- const size = Math.round(width);
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = size;
- can.height = size;
+ const size = Math.round(width);
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = size;
+ can.height = size;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- canctx.beginPath();
- canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI);
- canctx.fillStyle = color;
- canctx.fill();
- if (borderColor) {
- canctx.lineWidth = 2;
- canctx.strokeStyle = borderColor;
- canctx.stroke();
- }
-
- cachedGraphics[key] = can;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ canctx.beginPath();
+ canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI);
+ canctx.fillStyle = color;
+ canctx.fill();
+ if (borderColor) {
+ canctx.lineWidth = 2;
+ canctx.strokeStyle = borderColor;
+ canctx.stroke();
}
- ctx.drawImage(
- cachedGraphics[key],
- Math.round(x - size / 2),
- Math.round(y - size / 2),
- );
+
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(
+ cachedGraphics[key],
+ Math.round(x - size / 2),
+ Math.round(y - size / 2),
+ );
}
const angles = 32;
export function drawCoin(
- ctx: CanvasRenderingContext2D,
- color: colorString,
- size: number,
- x: number,
- y: number,
- borderColor: colorString,
- rawAngle: number,
+ ctx: CanvasRenderingContext2D,
+ color: colorString,
+ size: number,
+ x: number,
+ y: number,
+ borderColor: colorString,
+ rawAngle: number,
) {
- const angle =
- ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) %
- angles;
- const key =
- "coin with halo" +
- "_" +
- color +
- "_" +
- size +
- "_" +
- borderColor +
- "_" +
- (color === "gold" ? angle : "whatever");
+ const angle =
+ ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) %
+ angles;
+ const key =
+ "coin with halo" +
+ "_" +
+ color +
+ "_" +
+ size +
+ "_" +
+ borderColor +
+ "_" +
+ (color === "gold" ? angle : "whatever");
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = size;
- can.height = size;
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = size;
+ can.height = size;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- // coin
- canctx.beginPath();
- canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI);
- canctx.fillStyle = color;
- canctx.fill();
+ // coin
+ canctx.beginPath();
+ canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI);
+ canctx.fillStyle = color;
+ canctx.fill();
- canctx.strokeStyle = borderColor;
- if (borderColor == "red") {
- canctx.lineWidth = 2;
- canctx.setLineDash(redBorderDash);
- }
- canctx.stroke();
-
- if (color === "gold") {
- // Fill in
- canctx.beginPath();
- canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI);
- canctx.fillStyle = "rgba(255,255,255,0.5)";
- canctx.fill();
-
- canctx.translate(size / 2, size / 2);
- canctx.rotate(angle / 16);
- canctx.translate(-size / 2, -size / 2);
-
- canctx.globalCompositeOperation = "multiply";
- drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
- drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
- }
- cachedGraphics[key] = can;
+ canctx.strokeStyle = borderColor;
+ if (borderColor == "red") {
+ canctx.lineWidth = 2;
+ canctx.setLineDash(redBorderDash);
}
- ctx.drawImage(
- cachedGraphics[key],
- Math.round(x - size / 2),
- Math.round(y - size / 2),
- );
+ canctx.stroke();
+
+ if (color === "gold") {
+ // Fill in
+ canctx.beginPath();
+ canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI);
+ canctx.fillStyle = "rgba(255,255,255,0.5)";
+ canctx.fill();
+
+ canctx.translate(size / 2, size / 2);
+ canctx.rotate(angle / 16);
+ canctx.translate(-size / 2, -size / 2);
+
+ canctx.globalCompositeOperation = "multiply";
+ drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
+ drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1);
+ }
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(
+ cachedGraphics[key],
+ Math.round(x - size / 2),
+ Math.round(y - size / 2),
+ );
}
export function drawFuzzyBall(
- ctx: CanvasRenderingContext2D,
- color: colorString,
- width: number,
- x: number,
- y: number,
+ ctx: CanvasRenderingContext2D,
+ color: colorString,
+ width: number,
+ x: number,
+ y: number,
) {
- const key = "fuzzy-circle" + color + "_" + width;
- if (!color) debugger;
- const size = Math.round(width * 3);
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = size;
- can.height = size;
+ const key = "fuzzy-circle" + color + "_" + width;
+ if (!color) debugger;
+ const size = Math.round(width * 3);
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = size;
+ can.height = size;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- const gradient = canctx.createRadialGradient(
- size / 2,
- size / 2,
- 0,
- size / 2,
- size / 2,
- size / 2,
- );
- gradient.addColorStop(0, color);
- gradient.addColorStop(1, "transparent");
- canctx.fillStyle = gradient;
- canctx.fillRect(0, 0, size, size);
- cachedGraphics[key] = can;
- }
- ctx.drawImage(
- cachedGraphics[key],
- Math.round(x - size / 2),
- Math.round(y - size / 2),
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ const gradient = canctx.createRadialGradient(
+ size / 2,
+ size / 2,
+ 0,
+ size / 2,
+ size / 2,
+ size / 2,
);
+ gradient.addColorStop(0, color);
+ gradient.addColorStop(1, "transparent");
+ canctx.fillStyle = gradient;
+ canctx.fillRect(0, 0, size, size);
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(
+ cachedGraphics[key],
+ Math.round(x - size / 2),
+ Math.round(y - size / 2),
+ );
}
export function drawBrick(
- ctx: CanvasRenderingContext2D,
- color: colorString,
- x: number,
- y: number,
- offset: number = 0,
- borderOnly: boolean
+ ctx: CanvasRenderingContext2D,
+ color: colorString,
+ x: number,
+ y: number,
+ offset: number = 0,
+ borderOnly: boolean,
) {
- const tlx = Math.ceil(x - gameState.brickWidth / 2);
- const tly = Math.ceil(y - gameState.brickWidth / 2);
- const brx = Math.ceil(x + gameState.brickWidth / 2) - 1;
- const bry = Math.ceil(y + gameState.brickWidth / 2) - 1;
+ const tlx = Math.ceil(x - gameState.brickWidth / 2);
+ const tly = Math.ceil(y - gameState.brickWidth / 2);
+ const brx = Math.ceil(x + gameState.brickWidth / 2) - 1;
+ const bry = Math.ceil(y + gameState.brickWidth / 2) - 1;
- const width = brx - tlx,
- height = bry - tly;
- const key = "brick" + color + "_" + "_" + width + "_" + height + "_" + offset + '_' + borderOnly;
+ const width = brx - tlx,
+ height = bry - tly;
+ const key =
+ "brick" +
+ color +
+ "_" +
+ "_" +
+ width +
+ "_" +
+ height +
+ "_" +
+ offset +
+ "_" +
+ borderOnly;
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = width;
- can.height = height;
- const bord = 4;
- const cornerRadius = 2;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = width;
+ can.height = height;
+ const bord = 4;
+ const cornerRadius = 2;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- canctx.fillStyle = color;
+ canctx.fillStyle = color;
- canctx.setLineDash(offset !== -1 ? redBorderDash : emptyArray);
- canctx.lineDashOffset = offset;
- canctx.strokeStyle = offset !== -1 ? "red" : color;
- canctx.lineJoin = "round";
- canctx.lineWidth = bord;
- roundRect(
- canctx,
- bord / 2,
- bord / 2,
- width - bord,
- height - bord,
- cornerRadius,
- );
- if (!borderOnly) {
- canctx.fill();
- }
- canctx.stroke();
-
- cachedGraphics[key] = can;
+ canctx.setLineDash(offset !== -1 ? redBorderDash : emptyArray);
+ canctx.lineDashOffset = offset;
+ canctx.strokeStyle = offset !== -1 ? "red" : color;
+ canctx.lineJoin = "round";
+ canctx.lineWidth = bord;
+ roundRect(
+ canctx,
+ bord / 2,
+ bord / 2,
+ width - bord,
+ height - bord,
+ cornerRadius,
+ );
+ if (!borderOnly) {
+ canctx.fill();
}
- ctx.drawImage(cachedGraphics[key], tlx, tly, width, height);
- // It's not easy to have a 1px gap between bricks without antialiasing
+ canctx.stroke();
+
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(cachedGraphics[key], tlx, tly, width, height);
+ // It's not easy to have a 1px gap between bricks without antialiasing
}
export function roundRect(
- ctx: CanvasRenderingContext2D,
- x: number,
- y: number,
- width: number,
- height: number,
- radius: number,
+ ctx: CanvasRenderingContext2D,
+ x: number,
+ y: number,
+ width: number,
+ height: number,
+ radius: number,
) {
- ctx.beginPath();
- ctx.moveTo(x + radius, y);
- ctx.lineTo(x + width - radius, y);
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
- ctx.lineTo(x + width, y + height - radius);
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
- ctx.lineTo(x + radius, y + height);
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
- ctx.lineTo(x, y + radius);
- ctx.quadraticCurveTo(x, y, x + radius, y);
- ctx.closePath();
+ ctx.beginPath();
+ ctx.moveTo(x + radius, y);
+ ctx.lineTo(x + width - radius, y);
+ ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
+ ctx.lineTo(x + width, y + height - radius);
+ ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
+ ctx.lineTo(x + radius, y + height);
+ ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
+ ctx.lineTo(x, y + radius);
+ ctx.quadraticCurveTo(x, y, x + radius, y);
+ ctx.closePath();
}
export function drawIMG(
- ctx: CanvasRenderingContext2D,
- img: HTMLImageElement,
- size: number,
- x: number,
- y: number,
+ ctx: CanvasRenderingContext2D,
+ img: HTMLImageElement,
+ size: number,
+ x: number,
+ y: number,
) {
- const key = "svg" + img + "_" + size + "_" + img.complete;
+ const key = "svg" + img + "_" + size + "_" + img.complete;
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = size;
- can.height = size;
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = size;
+ can.height = size;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- const ratio = size / Math.max(img.width, img.height);
- const w = img.width * ratio;
- const h = img.height * ratio;
- canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
+ const ratio = size / Math.max(img.width, img.height);
+ const w = img.width * ratio;
+ const h = img.height * ratio;
+ canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h);
- cachedGraphics[key] = can;
- }
- ctx.drawImage(
- cachedGraphics[key],
- Math.round(x - size / 2),
- Math.round(y - size / 2),
- );
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(
+ cachedGraphics[key],
+ Math.round(x - size / 2),
+ Math.round(y - size / 2),
+ );
}
export function drawText(
- ctx: CanvasRenderingContext2D,
- text: string,
- color: colorString,
- fontSize: number,
- x: number,
- y: number,
- left = false,
+ ctx: CanvasRenderingContext2D,
+ text: string,
+ color: colorString,
+ fontSize: number,
+ x: number,
+ y: number,
+ left = false,
) {
- const key = "text" + text + "_" + color + "_" + fontSize + "_" + left;
+ const key = "text" + text + "_" + color + "_" + fontSize + "_" + left;
- if (!cachedGraphics[key]) {
- const can = document.createElement("canvas");
- can.width = fontSize * text.length;
- can.height = fontSize;
- const canctx = can.getContext("2d") as CanvasRenderingContext2D;
- canctx.fillStyle = color;
- canctx.textAlign = left ? "left" : "center";
- canctx.textBaseline = "middle";
- canctx.font = fontSize + "px monospace";
+ if (!cachedGraphics[key]) {
+ const can = document.createElement("canvas");
+ can.width = fontSize * text.length;
+ can.height = fontSize;
+ const canctx = can.getContext("2d") as CanvasRenderingContext2D;
+ canctx.fillStyle = color;
+ canctx.textAlign = left ? "left" : "center";
+ canctx.textBaseline = "middle";
+ canctx.font = fontSize + "px monospace";
- canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width);
+ canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width);
- cachedGraphics[key] = can;
- }
- ctx.drawImage(
- cachedGraphics[key],
- left ? x : Math.round(x - cachedGraphics[key].width / 2),
- Math.round(y - cachedGraphics[key].height / 2),
- );
+ cachedGraphics[key] = can;
+ }
+ ctx.drawImage(
+ cachedGraphics[key],
+ left ? x : Math.round(x - cachedGraphics[key].width / 2),
+ Math.round(y - cachedGraphics[key].height / 2),
+ );
}
export const scoreDisplay = document.getElementById(
- "score",
+ "score",
) as HTMLButtonElement;
const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement;
@@ -1002,8 +1022,8 @@ const emptyArray = [];
const redBorderDash = [5, 5];
export function getDashOffset(gameState: GameState) {
- if (isOptionOn("basic")) {
- return 0;
- }
- return Math.floor(((gameState.levelTime % 500) / 500) * 10) % 10;
+ if (isOptionOn("basic")) {
+ return 0;
+ }
+ return Math.floor(((gameState.levelTime % 500) / 500) * 10) % 10;
}
diff --git a/src/types.d.ts b/src/types.d.ts
index fd09e02..36df8f7 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -83,7 +83,7 @@ export type Coin = {
weight: number;
destroyed?: boolean;
collidedLastFrame?: boolean;
- metamorphosisPoints:number;
+ metamorphosisPoints: number;
};
export type Ball = {
x: number;
@@ -238,8 +238,12 @@ export type GameState = {
coins: ReusableArray;
// Bricks that should respawn destroyed
- respawns: ReusableArray<{ index: number; color: string ; time:number;
- destroyed?: boolean;}>;
+ respawns: ReusableArray<{
+ index: number;
+ color: string;
+ time: number;
+ destroyed?: boolean;
+ }>;
levelStartScore: number;
levelMisses: number;
@@ -280,14 +284,14 @@ export type GameState = {
rerolls: number;
loop: number;
baseCombo: number;
- levelsPerLoop:number;
+ levelsPerLoop: number;
};
export type RunParams = {
level?: string;
levelToAvoid?: string;
perks?: Partial;
- levelsPerLoop?:number;
+ levelsPerLoop?: number;
};
export type OptionDef = {
default: boolean;
diff --git a/src/upgrades.ts b/src/upgrades.ts
index 70f61b1..a69012b 100644
--- a/src/upgrades.ts
+++ b/src/upgrades.ts
@@ -1,6 +1,6 @@
import { t } from "./i18n/i18n";
-import {comboKeepingRate} from "./pure_functions";
+import { comboKeepingRate } from "./pure_functions";
export const rawUpgrades = [
{
@@ -49,7 +49,7 @@ export const rawUpgrades = [
id: "slow_down",
max: 2,
name: t("upgrades.slow_down.name"),
- help: (lvl:number) => t("upgrades.slow_down.help",{ lvl }),
+ help: (lvl: number) => t("upgrades.slow_down.help", { lvl }),
fullHelp: t("upgrades.slow_down.fullHelp"),
},
{
@@ -84,7 +84,7 @@ export const rawUpgrades = [
max: 1,
name: t("upgrades.left_is_lava.name"),
- help: (lvl:number) => t("upgrades.left_is_lava.help",{ lvl }),
+ help: (lvl: number) => t("upgrades.left_is_lava.help", { lvl }),
fullHelp: t("upgrades.left_is_lava.fullHelp"),
},
{
@@ -95,7 +95,7 @@ export const rawUpgrades = [
giftable: true,
max: 1,
name: t("upgrades.right_is_lava.name"),
- help: (lvl:number) => t("upgrades.right_is_lava.help",{ lvl }),
+ help: (lvl: number) => t("upgrades.right_is_lava.help", { lvl }),
fullHelp: t("upgrades.right_is_lava.fullHelp"),
},
{
@@ -106,7 +106,7 @@ export const rawUpgrades = [
giftable: true,
max: 1,
name: t("upgrades.top_is_lava.name"),
- help: (lvl:number) => t("upgrades.top_is_lava.help",{ lvl }),
+ help: (lvl: number) => t("upgrades.top_is_lava.help", { lvl }),
fullHelp: t("upgrades.top_is_lava.fullHelp"),
},
{
@@ -195,7 +195,7 @@ export const rawUpgrades = [
giftable: true,
max: 1,
name: t("upgrades.picky_eater.name"),
- help: (lvl: number) => t("upgrades.picky_eater.help",{lvl}),
+ help: (lvl: number) => t("upgrades.picky_eater.help", { lvl }),
fullHelp: t("upgrades.picky_eater.fullHelp"),
},
{
@@ -206,7 +206,7 @@ export const rawUpgrades = [
id: "metamorphosis",
max: 1,
name: t("upgrades.metamorphosis.name"),
- help: (lvl: number) => t("upgrades.metamorphosis.help",{lvl}),
+ help: (lvl: number) => t("upgrades.metamorphosis.help", { lvl }),
fullHelp: t("upgrades.metamorphosis.fullHelp"),
},
{
@@ -217,7 +217,7 @@ export const rawUpgrades = [
giftable: true,
max: 1,
name: t("upgrades.compound_interest.name"),
- help: (lvl: number) => t("upgrades.compound_interest.help",{lvl}),
+ help: (lvl: number) => t("upgrades.compound_interest.help", { lvl }),
fullHelp: t("upgrades.compound_interest.fullHelp"),
},
{
@@ -289,7 +289,10 @@ export const rawUpgrades = [
id: "soft_reset",
max: 3,
name: t("upgrades.soft_reset.name"),
- help: (lvl: number) => t("upgrades.soft_reset.help", { percent: Math.round(comboKeepingRate(lvl) * 100)}),
+ help: (lvl: number) =>
+ t("upgrades.soft_reset.help", {
+ percent: Math.round(comboKeepingRate(lvl) * 100),
+ }),
fullHelp: t("upgrades.soft_reset.fullHelp"),
},
{
@@ -354,9 +357,9 @@ export const rawUpgrades = [
name: t("upgrades.sturdy_bricks.name"),
help: (lvl: number) =>
// lvl == 1
- t("upgrades.sturdy_bricks.help",{lvl, percent:lvl*10}),
- // ?
- // : t("upgrades.sturdy_bricks.help_plural"),
+ t("upgrades.sturdy_bricks.help", { lvl, percent: lvl * 10 }),
+ // ?
+ // : t("upgrades.sturdy_bricks.help_plural"),
fullHelp: t("upgrades.sturdy_bricks.fullHelp"),
},
{
@@ -368,7 +371,10 @@ export const rawUpgrades = [
max: 4,
name: t("upgrades.respawn.name"),
help: (lvl: number) =>
- t("upgrades.respawn.help",{percent:Math.floor(100*comboKeepingRate(lvl)),delay:(3/lvl).toFixed(2)}),
+ t("upgrades.respawn.help", {
+ percent: Math.floor(100 * comboKeepingRate(lvl)),
+ delay: (3 / lvl).toFixed(2),
+ }),
fullHelp: t("upgrades.respawn.fullHelp"),
},
{
@@ -378,7 +384,7 @@ export const rawUpgrades = [
id: "one_more_choice",
max: 3,
name: t("upgrades.one_more_choice.name"),
- help: (lvl: number) => t("upgrades.one_more_choice.help", {lvl}),
+ help: (lvl: number) => t("upgrades.one_more_choice.help", { lvl }),
fullHelp: t("upgrades.one_more_choice.fullHelp"),
},
{
@@ -390,7 +396,7 @@ export const rawUpgrades = [
max: 2,
adventure: false,
name: t("upgrades.instant_upgrade.name"),
- help: (lvl: number) => t("upgrades.instant_upgrade.help",{lvl}),
+ help: (lvl: number) => t("upgrades.instant_upgrade.help", { lvl }),
fullHelp: t("upgrades.instant_upgrade.fullHelp"),
},
{
@@ -422,7 +428,7 @@ export const rawUpgrades = [
id: "asceticism",
max: 1,
name: t("upgrades.asceticism.name"),
- help: (lvl: number) => t("upgrades.asceticism.help",{combo:lvl*3}),
+ help: (lvl: number) => t("upgrades.asceticism.help", { combo: lvl * 3 }),
fullHelp: t("upgrades.asceticism.fullHelp"),
},
{
@@ -433,9 +439,10 @@ export const rawUpgrades = [
id: "unbounded",
max: 1,
name: t("upgrades.unbounded.name"),
- help: (lvl: number) => lvl > 1 ?
- t("upgrades.unbounded.help_no_ceiling",{lvl}):
- t("upgrades.unbounded.help",{lvl}),
+ help: (lvl: number) =>
+ lvl > 1
+ ? t("upgrades.unbounded.help_no_ceiling", { lvl })
+ : t("upgrades.unbounded.help", { lvl }),
fullHelp: t("upgrades.unbounded.fullHelp"),
},
{
@@ -446,7 +453,10 @@ export const rawUpgrades = [
id: "shunt",
max: 3,
name: t("upgrades.shunt.name"),
- help: (lvl: number) => t("upgrades.shunt.help", { percent: Math.round(comboKeepingRate(lvl) * 100) }),
+ help: (lvl: number) =>
+ t("upgrades.shunt.help", {
+ percent: Math.round(comboKeepingRate(lvl) * 100),
+ }),
fullHelp: t("upgrades.shunt.fullHelp"),
},
{
@@ -499,7 +509,7 @@ export const rawUpgrades = [
id: "zen",
max: 1,
name: t("upgrades.zen.name"),
- help: (lvl: number) => t("upgrades.zen.help",{lvl}),
+ help: (lvl: number) => t("upgrades.zen.help", { lvl }),
fullHelp: t("upgrades.zen.fullHelp"),
},
{
@@ -510,9 +520,9 @@ export const rawUpgrades = [
max: 1,
name: t("upgrades.sacrifice.name"),
help: (lvl: number) =>
- lvl==1 ?
- t("upgrades.sacrifice.help_l1"):
- t("upgrades.sacrifice.help_over",{lvl}),
+ lvl == 1
+ ? t("upgrades.sacrifice.help_l1")
+ : t("upgrades.sacrifice.help_over", { lvl }),
fullHelp: t("upgrades.sacrifice.fullHelp"),
},