2025-03-16 17:45:29 +01:00
|
|
|
import { allLevels, appVersion, icons, upgrades } from "./loadGameData";
|
2025-03-16 14:29:14 +01:00
|
|
|
import {
|
2025-03-16 17:45:29 +01:00
|
|
|
Ball,
|
|
|
|
Coin,
|
|
|
|
GameState,
|
2025-03-18 14:16:12 +01:00
|
|
|
LightFlash,
|
2025-03-16 17:45:29 +01:00
|
|
|
OptionId,
|
2025-03-18 14:16:12 +01:00
|
|
|
ParticleFlash,
|
2025-03-16 17:45:29 +01:00
|
|
|
PerkId,
|
|
|
|
RunParams,
|
2025-03-18 14:16:12 +01:00
|
|
|
TextFlash,
|
2025-03-16 17:45:29 +01:00
|
|
|
Upgrade,
|
|
|
|
} from "./types";
|
2025-03-18 14:16:12 +01:00
|
|
|
import { getAudioContext, playPendingSounds } from "./sounds";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
|
|
|
currentLevelInfo,
|
|
|
|
getRowColIndex,
|
|
|
|
max_levels,
|
|
|
|
pickedUpgradesHTMl,
|
|
|
|
} from "./game_utils";
|
|
|
|
|
|
|
|
import "./PWA/sw_loader";
|
|
|
|
import { getCurrentLang, t } from "./i18n/i18n";
|
|
|
|
import { getSettingValue, getTotalScore, setSettingValue } from "./settings";
|
|
|
|
import {
|
2025-03-18 14:16:12 +01:00
|
|
|
empty,
|
|
|
|
forEachLiveOne,
|
2025-03-16 17:45:29 +01:00
|
|
|
gameStateTick,
|
|
|
|
normalizeGameState,
|
|
|
|
pickRandomUpgrades,
|
|
|
|
putBallsAtPuck,
|
|
|
|
resetBalls,
|
|
|
|
resetCombo,
|
|
|
|
setLevel,
|
|
|
|
setMousePos,
|
2025-03-16 14:29:14 +01:00
|
|
|
} from "./gameStateMutators";
|
2025-03-16 17:45:29 +01:00
|
|
|
import {
|
|
|
|
backgroundCanvas,
|
|
|
|
bombSVG,
|
|
|
|
ctx,
|
|
|
|
gameCanvas,
|
|
|
|
render,
|
|
|
|
scoreDisplay,
|
|
|
|
} from "./render";
|
|
|
|
import {
|
|
|
|
pauseRecording,
|
|
|
|
recordOneFrame,
|
|
|
|
resumeRecording,
|
|
|
|
startRecordingGame,
|
|
|
|
} from "./recording";
|
|
|
|
import { newGameState } from "./newGameState";
|
|
|
|
import {
|
|
|
|
alertsOpen,
|
|
|
|
asyncAlert,
|
|
|
|
AsyncAlertAction,
|
|
|
|
closeModal,
|
|
|
|
} from "./asyncAlert";
|
|
|
|
import { isOptionOn, options, toggleOption } from "./options";
|
2025-03-18 14:16:12 +01:00
|
|
|
import { hashCode } from "./getLevelBackground";
|
2025-03-05 22:10:17 +01:00
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function play() {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.running) return;
|
|
|
|
gameState.running = true;
|
2025-03-18 14:16:12 +01:00
|
|
|
gameState.ballStickToPuck = false;
|
2025-03-13 14:14:00 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
startRecordingGame(gameState);
|
|
|
|
getAudioContext()?.resume();
|
|
|
|
resumeRecording();
|
|
|
|
document.body.className = gameState.running ? " running " : " paused ";
|
2025-02-16 21:21:12 +01:00
|
|
|
}
|
2025-02-17 00:49:03 +01:00
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function pause(playerAskedForPause: boolean) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.running) return;
|
|
|
|
if (gameState.pauseTimeout) return;
|
|
|
|
|
|
|
|
gameState.pauseTimeout = setTimeout(
|
|
|
|
() => {
|
|
|
|
gameState.running = false;
|
|
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
if (!gameState.running) getAudioContext()?.suspend();
|
|
|
|
}, 1000);
|
|
|
|
|
|
|
|
pauseRecording();
|
|
|
|
gameState.pauseTimeout = null;
|
|
|
|
document.body.className = gameState.running ? " running " : " paused ";
|
|
|
|
scoreDisplay.className = "";
|
2025-03-16 20:11:27 +01:00
|
|
|
gameState.needsRender = true;
|
2025-03-16 17:45:29 +01:00
|
|
|
},
|
|
|
|
Math.min(Math.max(0, gameState.pauseUsesDuringRun - 5) * 50, 500),
|
|
|
|
);
|
|
|
|
|
|
|
|
if (playerAskedForPause) {
|
|
|
|
// Pausing many times in a run will make pause slower
|
|
|
|
gameState.pauseUsesDuringRun++;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (document.exitPointerLock) {
|
|
|
|
document.exitPointerLock();
|
|
|
|
}
|
2025-03-12 15:15:30 +01:00
|
|
|
}
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
export const fitSize = () => {
|
2025-03-18 14:16:12 +01:00
|
|
|
const past_off = gameState.offsetXRoundedDown,
|
|
|
|
past_width = gameState.gameZoneWidthRoundedUp,
|
|
|
|
past_heigh = gameState.gameZoneHeight;
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
const { width, height } = gameCanvas.getBoundingClientRect();
|
|
|
|
gameState.canvasWidth = width;
|
|
|
|
gameState.canvasHeight = height;
|
|
|
|
gameCanvas.width = width;
|
|
|
|
gameCanvas.height = height;
|
|
|
|
ctx.fillStyle = currentLevelInfo(gameState)?.color || "black";
|
|
|
|
ctx.globalAlpha = 1;
|
|
|
|
ctx.fillRect(0, 0, width, height);
|
|
|
|
backgroundCanvas.width = width;
|
|
|
|
backgroundCanvas.height = height;
|
|
|
|
|
|
|
|
gameState.gameZoneHeight = isOptionOn("mobile-mode")
|
|
|
|
? (height * 80) / 100
|
|
|
|
: height;
|
|
|
|
const baseWidth = Math.round(
|
|
|
|
Math.min(gameState.canvasWidth, gameState.gameZoneHeight * 0.73),
|
|
|
|
);
|
|
|
|
gameState.brickWidth = Math.floor(baseWidth / gameState.gridSize / 2) * 2;
|
|
|
|
gameState.gameZoneWidth = gameState.brickWidth * gameState.gridSize;
|
|
|
|
gameState.offsetX = Math.floor(
|
|
|
|
(gameState.canvasWidth - gameState.gameZoneWidth) / 2,
|
|
|
|
);
|
|
|
|
gameState.offsetXRoundedDown = gameState.offsetX;
|
|
|
|
if (gameState.offsetX < gameState.ballSize) gameState.offsetXRoundedDown = 0;
|
|
|
|
gameState.gameZoneWidthRoundedUp = width - 2 * gameState.offsetXRoundedDown;
|
|
|
|
backgroundCanvas.title = "resized";
|
|
|
|
// Ensure puck stays within bounds
|
|
|
|
setMousePos(gameState, gameState.puckPosition);
|
2025-03-18 14:16:12 +01:00
|
|
|
|
|
|
|
function mapXY(item: ParticleFlash | TextFlash | LightFlash) {
|
|
|
|
item.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
((item.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
|
|
|
|
item.y = (item.y / past_heigh) * gameState.gameZoneHeight;
|
|
|
|
}
|
|
|
|
function mapXYPastCoord(coin: Coin | Ball) {
|
|
|
|
coin.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
((coin.x - past_off) / past_width) * gameState.gameZoneWidthRoundedUp;
|
|
|
|
coin.y = (coin.y / past_heigh) * gameState.gameZoneHeight;
|
|
|
|
coin.previousX = coin.x;
|
|
|
|
coin.previousY = coin.y;
|
|
|
|
}
|
|
|
|
gameState.balls.forEach(mapXYPastCoord);
|
|
|
|
forEachLiveOne(gameState.coins, mapXYPastCoord);
|
|
|
|
forEachLiveOne(gameState.particles, mapXY);
|
|
|
|
forEachLiveOne(gameState.texts, mapXY);
|
|
|
|
forEachLiveOne(gameState.lights, mapXY);
|
2025-03-16 17:45:29 +01:00
|
|
|
pause(true);
|
|
|
|
// For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/
|
|
|
|
document.documentElement.style.setProperty(
|
|
|
|
"--vh",
|
|
|
|
`${window.innerHeight * 0.01}px`,
|
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
};
|
|
|
|
window.addEventListener("resize", fitSize);
|
2025-03-01 21:59:41 +01:00
|
|
|
window.addEventListener("fullscreenchange", fitSize);
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-11 13:56:42 +01:00
|
|
|
setInterval(() => {
|
2025-03-16 17:45:29 +01:00
|
|
|
// Sometimes, the page changes size without triggering the event (when switching to fullscreen, closing debug panel...)
|
|
|
|
const { width, height } = gameCanvas.getBoundingClientRect();
|
|
|
|
if (width !== gameState.canvasWidth || height !== gameState.canvasHeight)
|
|
|
|
fitSize();
|
2025-03-11 13:56:42 +01:00
|
|
|
}, 1000);
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
export async function openUpgradesPicker(gameState: GameState) {
|
|
|
|
const catchRate =
|
|
|
|
(gameState.score - gameState.levelStartScore) /
|
|
|
|
(gameState.levelSpawnedCoins || 1);
|
|
|
|
|
|
|
|
let repeats = 1;
|
|
|
|
let choices = 3;
|
|
|
|
|
|
|
|
let timeGain = "",
|
|
|
|
catchGain = "",
|
|
|
|
wallHitsGain = "",
|
|
|
|
missesGain = "";
|
|
|
|
|
|
|
|
if (gameState.levelWallBounces == 0) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
wallHitsGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelWallBounces < 5) {
|
|
|
|
choices++;
|
|
|
|
wallHitsGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (gameState.levelTime < 30 * 1000) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
timeGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelTime < 60 * 1000) {
|
|
|
|
choices++;
|
|
|
|
timeGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (catchRate === 1) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
catchGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (catchRate > 0.9) {
|
|
|
|
choices++;
|
|
|
|
catchGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
if (gameState.levelMisses === 0) {
|
|
|
|
repeats++;
|
|
|
|
choices++;
|
|
|
|
missesGain = t("level_up.plus_one_upgrade");
|
|
|
|
} else if (gameState.levelMisses <= 3) {
|
|
|
|
choices++;
|
|
|
|
missesGain = t("level_up.plus_one_choice");
|
|
|
|
}
|
|
|
|
|
|
|
|
while (repeats--) {
|
|
|
|
const actions = pickRandomUpgrades(
|
|
|
|
gameState,
|
|
|
|
choices +
|
|
|
|
gameState.perks.one_more_choice -
|
|
|
|
gameState.perks.instant_upgrade,
|
|
|
|
);
|
|
|
|
if (!actions.length) break;
|
|
|
|
let textAfterButtons = `
|
|
|
|
<p>${t("level_up.after_buttons", {
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
2025-03-15 21:29:38 +01:00
|
|
|
})} </p>
|
2025-03-16 14:29:14 +01:00
|
|
|
<p>${pickedUpgradesHTMl(gameState)}</p>
|
2025-03-14 11:59:49 +01:00
|
|
|
<div id="level-recording-container"></div>
|
|
|
|
|
|
|
|
`;
|
2025-03-13 08:53:02 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
const compliment =
|
|
|
|
(timeGain &&
|
|
|
|
catchGain &&
|
|
|
|
missesGain &&
|
|
|
|
wallHitsGain &&
|
|
|
|
t("level_up.compliment_perfect")) ||
|
|
|
|
((timeGain || catchGain || missesGain || wallHitsGain) &&
|
|
|
|
t("level_up.compliment_good")) ||
|
|
|
|
t("level_up.compliment_advice");
|
|
|
|
|
|
|
|
const upgradeId = (await asyncAlert<PerkId>({
|
|
|
|
title:
|
|
|
|
t("level_up.pick_upgrade_title") +
|
|
|
|
(repeats ? " (" + (repeats + 1) + ")" : ""),
|
|
|
|
actions,
|
|
|
|
text: `<p>${t("level_up.before_buttons", {
|
|
|
|
score: gameState.score - gameState.levelStartScore,
|
|
|
|
catchGain,
|
|
|
|
levelSpawnedCoins: gameState.levelSpawnedCoins,
|
|
|
|
time: Math.round(gameState.levelTime / 1000),
|
|
|
|
timeGain,
|
|
|
|
levelMisses: gameState.levelMisses,
|
|
|
|
missesGain,
|
|
|
|
levelWallBounces: gameState.levelWallBounces,
|
|
|
|
wallHitsGain,
|
|
|
|
compliment,
|
|
|
|
})}
|
2025-03-14 11:59:49 +01:00
|
|
|
</p>`,
|
2025-03-16 17:45:29 +01:00
|
|
|
allowClose: false,
|
|
|
|
textAfterButtons,
|
|
|
|
})) as PerkId;
|
2025-03-15 10:34:01 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
gameState.perks[upgradeId]++;
|
|
|
|
if (upgradeId === "instant_upgrade") {
|
|
|
|
repeats += 2;
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
|
|
|
|
gameState.runStatistics.upgrades_picked++;
|
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mouseup", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (e.button !== 0) return;
|
|
|
|
if (gameState.running) {
|
|
|
|
pause(true);
|
|
|
|
} else {
|
|
|
|
play();
|
2025-03-17 11:50:13 +01:00
|
|
|
if (isOptionOn("pointerLock") && gameCanvas.requestPointerLock) {
|
|
|
|
gameCanvas.requestPointerLock().then();
|
2025-02-16 21:21:12 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("mousemove", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.pointerLockElement === gameCanvas) {
|
|
|
|
setMousePos(gameState, gameState.puckPosition + e.movementX);
|
|
|
|
} else {
|
|
|
|
setMousePos(gameState, e.x);
|
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchstart", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
if (!e.touches?.length) return;
|
2025-03-18 14:16:12 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
setMousePos(gameState, e.touches[0].pageX);
|
2025-03-18 14:16:12 +01:00
|
|
|
normalizeGameState(gameState);
|
2025-03-16 17:45:29 +01:00
|
|
|
play();
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchend", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
pause(true);
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchcancel", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
pause(true);
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
2025-03-07 11:34:11 +01:00
|
|
|
gameCanvas.addEventListener("touchmove", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!e.touches?.length) return;
|
|
|
|
setMousePos(gameState, e.touches[0].pageX);
|
2025-03-16 14:29:14 +01:00
|
|
|
});
|
2025-03-12 15:15:30 +01:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function brickIndex(x: number, y: number) {
|
2025-03-16 17:45:29 +01:00
|
|
|
return getRowColIndex(
|
|
|
|
gameState,
|
|
|
|
Math.floor(y / gameState.brickWidth),
|
|
|
|
Math.floor((x - gameState.offsetX) / gameState.brickWidth),
|
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function hasBrick(index: number): number | undefined {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.bricks[index]) return index;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-15 10:34:01 +01:00
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function hitsSomething(x: number, y: number, radius: number) {
|
2025-03-16 17:45:29 +01:00
|
|
|
return (
|
|
|
|
hasBrick(brickIndex(x - radius, y - radius)) ??
|
|
|
|
hasBrick(brickIndex(x + radius, y - radius)) ??
|
|
|
|
hasBrick(brickIndex(x + radius, y + radius)) ??
|
|
|
|
hasBrick(brickIndex(x - radius, y + radius))
|
|
|
|
);
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function shouldPierceByColor(
|
2025-03-16 17:45:29 +01:00
|
|
|
vhit: number | undefined,
|
|
|
|
hhit: number | undefined,
|
|
|
|
chit: number | undefined,
|
2025-03-07 20:18:18 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.perks.pierce_color) return false;
|
|
|
|
if (
|
|
|
|
typeof vhit !== "undefined" &&
|
|
|
|
gameState.bricks[vhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof hhit !== "undefined" &&
|
|
|
|
gameState.bricks[hhit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
if (
|
|
|
|
typeof chit !== "undefined" &&
|
|
|
|
gameState.bricks[chit] !== gameState.ballsColor
|
|
|
|
) {
|
|
|
|
return false;
|
|
|
|
}
|
|
|
|
return true;
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function coinBrickHitCheck(coin: Coin) {
|
2025-03-16 17:45:29 +01:00
|
|
|
// Make ball/coin bonce, and return bricks that were hit
|
|
|
|
const radius = coin.size / 2;
|
|
|
|
const { x, y, previousX, previousY } = coin;
|
|
|
|
|
|
|
|
const vhit = hitsSomething(previousX, y, radius);
|
|
|
|
const hhit = hitsSomething(x, previousY, radius);
|
|
|
|
const chit =
|
|
|
|
(typeof vhit == "undefined" &&
|
|
|
|
typeof hhit == "undefined" &&
|
|
|
|
hitsSomething(x, y, radius)) ||
|
|
|
|
undefined;
|
|
|
|
|
|
|
|
if (typeof vhit !== "undefined" || typeof chit !== "undefined") {
|
|
|
|
coin.y = coin.previousY;
|
|
|
|
coin.vy *= -1;
|
|
|
|
|
|
|
|
// Roll on corners
|
|
|
|
const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)];
|
|
|
|
const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)];
|
|
|
|
|
|
|
|
if (leftHit && !rightHit) {
|
|
|
|
coin.vx += 1;
|
|
|
|
coin.sa -= 1;
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!leftHit && rightHit) {
|
|
|
|
coin.vx -= 1;
|
|
|
|
coin.sa += 1;
|
2025-03-16 14:29:14 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
if (typeof hhit !== "undefined" || typeof chit !== "undefined") {
|
|
|
|
coin.x = coin.previousX;
|
|
|
|
coin.vx *= -1;
|
|
|
|
}
|
|
|
|
return vhit ?? hhit ?? chit;
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 14:29:14 +01:00
|
|
|
export function bordersHitCheck(
|
2025-03-16 17:45:29 +01:00
|
|
|
coin: Coin | Ball,
|
|
|
|
radius: number,
|
|
|
|
delta: number,
|
2025-03-07 20:18:18 +01:00
|
|
|
) {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (coin.destroyed) return;
|
|
|
|
coin.previousX = coin.x;
|
|
|
|
coin.previousY = coin.y;
|
|
|
|
coin.x += coin.vx * delta;
|
|
|
|
coin.y += coin.vy * delta;
|
|
|
|
coin.sx ||= 0;
|
|
|
|
coin.sy ||= 0;
|
|
|
|
coin.sx += coin.previousX - coin.x;
|
|
|
|
coin.sy += coin.previousY - coin.y;
|
|
|
|
coin.sx *= 0.9;
|
|
|
|
coin.sy *= 0.9;
|
|
|
|
|
|
|
|
if (gameState.perks.wind) {
|
|
|
|
coin.vx +=
|
|
|
|
((gameState.puckPosition -
|
|
|
|
(gameState.offsetX + gameState.gameZoneWidth / 2)) /
|
|
|
|
gameState.gameZoneWidth) *
|
|
|
|
gameState.perks.wind *
|
|
|
|
0.5;
|
|
|
|
}
|
|
|
|
|
|
|
|
let vhit = 0,
|
|
|
|
hhit = 0;
|
|
|
|
|
|
|
|
if (coin.x < gameState.offsetXRoundedDown + radius) {
|
|
|
|
coin.x =
|
|
|
|
gameState.offsetXRoundedDown +
|
|
|
|
radius +
|
|
|
|
(gameState.offsetXRoundedDown + radius - coin.x);
|
|
|
|
coin.vx *= -1;
|
|
|
|
hhit = 1;
|
|
|
|
}
|
|
|
|
if (coin.y < radius) {
|
|
|
|
coin.y = radius + (radius - coin.y);
|
|
|
|
coin.vy *= -1;
|
|
|
|
vhit = 1;
|
|
|
|
}
|
|
|
|
if (coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius) {
|
|
|
|
coin.x =
|
|
|
|
gameState.canvasWidth -
|
|
|
|
gameState.offsetXRoundedDown -
|
|
|
|
radius -
|
|
|
|
(coin.x -
|
|
|
|
(gameState.canvasWidth - gameState.offsetXRoundedDown - radius));
|
|
|
|
coin.vx *= -1;
|
|
|
|
hhit = 1;
|
|
|
|
}
|
|
|
|
|
|
|
|
return hhit + vhit * 2;
|
2025-03-14 11:59:49 +01:00
|
|
|
}
|
2025-03-16 14:29:14 +01:00
|
|
|
export function tick() {
|
2025-03-16 17:45:29 +01:00
|
|
|
const currentTick = performance.now();
|
|
|
|
const timeDeltaMs = currentTick - gameState.lastTick;
|
|
|
|
gameState.lastTick = currentTick;
|
2025-03-14 11:59:49 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
const frames = Math.min(4, timeDeltaMs / (1000 / 60));
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
if (gameState.keyboardPuckSpeed) {
|
|
|
|
setMousePos(
|
|
|
|
gameState,
|
|
|
|
gameState.puckPosition + gameState.keyboardPuckSpeed,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
normalizeGameState(gameState);
|
|
|
|
|
|
|
|
if (gameState.running) {
|
|
|
|
gameState.levelTime += timeDeltaMs;
|
|
|
|
gameState.runStatistics.runTime += timeDeltaMs;
|
|
|
|
gameStateTick(gameState, frames);
|
|
|
|
}
|
2025-03-16 20:11:27 +01:00
|
|
|
if (gameState.running || gameState.needsRender) {
|
|
|
|
gameState.needsRender = false;
|
2025-03-16 20:11:08 +01:00
|
|
|
render(gameState);
|
|
|
|
}
|
2025-03-16 20:11:27 +01:00
|
|
|
if (gameState.running) {
|
2025-03-16 20:11:08 +01:00
|
|
|
recordOneFrame(gameState);
|
|
|
|
}
|
2025-03-18 14:16:12 +01:00
|
|
|
if (isOptionOn("sound")) {
|
|
|
|
playPendingSounds(gameState);
|
2025-03-17 19:47:16 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
requestAnimationFrame(tick);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
window.addEventListener("visibilitychange", () => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.hidden) {
|
|
|
|
pause(true);
|
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
});
|
|
|
|
|
2025-03-07 11:34:11 +01:00
|
|
|
scoreDisplay.addEventListener("click", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
e.preventDefault();
|
|
|
|
openScorePanel();
|
2025-02-27 18:56:04 +01:00
|
|
|
});
|
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
document.addEventListener("visibilitychange", () => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (document.hidden) {
|
|
|
|
pause(true);
|
|
|
|
}
|
2025-03-15 10:34:01 +01:00
|
|
|
});
|
|
|
|
|
2025-03-01 14:20:27 +01:00
|
|
|
async function openScorePanel() {
|
2025-03-16 17:45:29 +01:00
|
|
|
pause(true);
|
|
|
|
const cb = await asyncAlert({
|
|
|
|
title: t("score_panel.title", {
|
|
|
|
score: gameState.score,
|
|
|
|
level: gameState.currentLevel + 1,
|
|
|
|
max: max_levels(gameState),
|
|
|
|
}),
|
|
|
|
text: `
|
2025-03-15 21:29:38 +01:00
|
|
|
${gameState.isCreativeModeRun ? "<p>${t('score_panel.test_run}</p>" : ""}
|
2025-03-16 17:45:29 +01:00
|
|
|
<p>${t("score_panel.upgrades_picked")}</p>
|
2025-03-16 14:29:14 +01:00
|
|
|
<p>${pickedUpgradesHTMl(gameState)}</p>
|
2025-03-06 16:46:25 +01:00
|
|
|
`,
|
2025-03-16 17:45:29 +01:00
|
|
|
allowClose: true,
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: t("score_panel.resume"),
|
|
|
|
help: t("score_panel.resume_help"),
|
|
|
|
value: () => {},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("score_panel.restart"),
|
|
|
|
help: t("score_panel.restart_help"),
|
|
|
|
value: () => {
|
|
|
|
restart({ levelToAvoid: currentLevelInfo(gameState).name });
|
|
|
|
},
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
|
|
|
}
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
(document.getElementById("menu") as HTMLButtonElement).addEventListener(
|
|
|
|
"click",
|
|
|
|
(e) => {
|
|
|
|
e.preventDefault();
|
|
|
|
openSettingsPanel();
|
|
|
|
},
|
|
|
|
);
|
2025-02-15 19:21:00 +01:00
|
|
|
|
|
|
|
async function openSettingsPanel() {
|
2025-03-16 17:45:29 +01:00
|
|
|
pause(true);
|
|
|
|
|
|
|
|
const actions: AsyncAlertAction<() => void>[] = [
|
|
|
|
{
|
|
|
|
text: t("main_menu.resume"),
|
|
|
|
help: t("main_menu.resume_help"),
|
|
|
|
value() {},
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("main_menu.unlocks"),
|
|
|
|
help: t("main_menu.unlocks_help"),
|
|
|
|
value() {
|
|
|
|
openUnlocksList();
|
|
|
|
},
|
|
|
|
},
|
|
|
|
];
|
|
|
|
|
|
|
|
for (const key of Object.keys(options) as OptionId[]) {
|
|
|
|
if (options[key])
|
|
|
|
actions.push({
|
|
|
|
icon: isOptionOn(key)
|
|
|
|
? icons["icon:checkmark_checked"]
|
|
|
|
: icons["icon:checkmark_unchecked"],
|
|
|
|
text: options[key].name,
|
|
|
|
help: options[key].help,
|
|
|
|
value: () => {
|
|
|
|
toggleOption(key);
|
|
|
|
if (key === "mobile-mode") fitSize();
|
|
|
|
|
|
|
|
openSettingsPanel();
|
2025-03-14 11:59:49 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold));
|
|
|
|
|
|
|
|
if (document.fullscreenEnabled || document.webkitFullscreenEnabled) {
|
|
|
|
if (document.fullscreenElement !== null) {
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.fullscreen_exit"),
|
|
|
|
help: t("main_menu.fullscreen_exit_help"),
|
|
|
|
icon: icons["icon:exit_fullscreen"],
|
|
|
|
value() {
|
|
|
|
toggleFullScreen();
|
2025-03-15 21:29:38 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
|
|
|
} else {
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.fullscreen"),
|
|
|
|
help: t("main_menu.fullscreen_help"),
|
2025-03-15 21:29:38 +01:00
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
icon: icons["icon:fullscreen"],
|
|
|
|
value() {
|
|
|
|
toggleFullScreen();
|
2025-03-15 21:29:38 +01:00
|
|
|
},
|
2025-03-16 17:45:29 +01:00
|
|
|
});
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
actions.push({
|
|
|
|
text: t("sandbox.title"),
|
|
|
|
help:
|
|
|
|
getTotalScore() < creativeModeThreshold
|
|
|
|
? t("sandbox.unlocks_at", { score: creativeModeThreshold })
|
|
|
|
: t("sandbox.help"),
|
|
|
|
disabled: getTotalScore() < creativeModeThreshold,
|
|
|
|
async value() {
|
|
|
|
let creativeModePerks: Partial<{ [id in PerkId]: number }> =
|
|
|
|
getSettingValue("creativeModePerks", {}),
|
|
|
|
choice: "start" | Upgrade | void;
|
|
|
|
|
|
|
|
while (
|
|
|
|
(choice = await asyncAlert<"start" | Upgrade>({
|
|
|
|
title: t("sandbox.title"),
|
|
|
|
text: t("sandbox.instructions"),
|
|
|
|
actionsAsGrid: true,
|
|
|
|
actions: [
|
|
|
|
...upgrades.map((u) => ({
|
|
|
|
icon: u.icon,
|
|
|
|
text: u.name,
|
|
|
|
help: (creativeModePerks[u.id] || 0) + "/" + u.max,
|
|
|
|
value: u,
|
|
|
|
className: creativeModePerks[u.id]
|
|
|
|
? ""
|
|
|
|
: "grey-out-unless-hovered",
|
|
|
|
})),
|
|
|
|
{
|
|
|
|
text: t("sandbox.start"),
|
|
|
|
value: "start",
|
|
|
|
},
|
|
|
|
],
|
|
|
|
}))
|
|
|
|
) {
|
|
|
|
if (choice === "start") {
|
|
|
|
setSettingValue("creativeModePerks", creativeModePerks);
|
|
|
|
restart({ perks: creativeModePerks });
|
|
|
|
|
|
|
|
break;
|
|
|
|
} else if (choice) {
|
|
|
|
creativeModePerks[choice.id] =
|
|
|
|
((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.reset"),
|
|
|
|
help: t("main_menu.reset_help"),
|
|
|
|
async value() {
|
|
|
|
if (
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.reset"),
|
|
|
|
text: t("main_menu.reset_instruction"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_confirm"),
|
|
|
|
value: true,
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: t("main_menu.reset_cancel"),
|
|
|
|
value: false,
|
|
|
|
},
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
})
|
|
|
|
) {
|
|
|
|
localStorage.clear();
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-03-17 11:50:13 +01:00
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.download_save_file"),
|
|
|
|
help: t("main_menu.download_save_file_help"),
|
|
|
|
async value() {
|
|
|
|
const localStorageContent: Record<string, string> = {};
|
|
|
|
|
|
|
|
for (let i = 0; i < localStorage.length; i++) {
|
|
|
|
const key = localStorage.key(i) as string;
|
|
|
|
const value = localStorage.getItem(key) as string;
|
|
|
|
|
|
|
|
// Store the key-value pair in the object
|
|
|
|
localStorageContent[key] = value;
|
|
|
|
}
|
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
const signedPayload = JSON.stringify(localStorageContent);
|
2025-03-17 11:50:13 +01:00
|
|
|
const dlLink = document.createElement("a");
|
|
|
|
|
|
|
|
dlLink.setAttribute(
|
|
|
|
"href",
|
|
|
|
"data:application/json;base64," +
|
|
|
|
btoa(
|
|
|
|
JSON.stringify({
|
|
|
|
fileType: "B71-save-file",
|
|
|
|
appVersion,
|
2025-03-17 19:02:19 +01:00
|
|
|
signedPayload,
|
2025-03-18 14:16:12 +01:00
|
|
|
key: hashCode(
|
|
|
|
"Security by obscurity, but really the game is oss so eh" +
|
|
|
|
signedPayload,
|
|
|
|
),
|
2025-03-17 11:50:13 +01:00
|
|
|
}),
|
|
|
|
),
|
|
|
|
);
|
|
|
|
|
|
|
|
dlLink.setAttribute(
|
|
|
|
"download",
|
|
|
|
"b71-save-" +
|
|
|
|
new Date()
|
|
|
|
.toISOString()
|
|
|
|
.slice(0, 19)
|
|
|
|
.replace(/[^0-9]+/gi, "-") +
|
|
|
|
".b71",
|
|
|
|
);
|
|
|
|
document.body.appendChild(dlLink);
|
|
|
|
dlLink.click();
|
|
|
|
setTimeout(() => document.body.removeChild(dlLink), 1000);
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.load_save_file"),
|
|
|
|
help: t("main_menu.load_save_file_help"),
|
|
|
|
async value() {
|
|
|
|
if (!document.getElementById("save_file_picker")) {
|
|
|
|
let input: HTMLInputElement = document.createElement("input");
|
|
|
|
input.setAttribute("type", "file");
|
|
|
|
input.setAttribute("id", "save_file_picker");
|
2025-03-18 15:49:11 +01:00
|
|
|
input.setAttribute("accept", ".b71,.json");
|
2025-03-17 11:50:13 +01:00
|
|
|
input.style.position = "absolute";
|
|
|
|
input.style.left = "-1000px";
|
|
|
|
input.addEventListener("change", async (e) => {
|
|
|
|
try {
|
|
|
|
const file = input && input.files?.item(0);
|
|
|
|
if (file) {
|
|
|
|
const content = await new Promise<string>((resolve, reject) => {
|
|
|
|
const reader = new FileReader();
|
|
|
|
reader.onload = function () {
|
|
|
|
resolve(reader.result?.toString() || "");
|
|
|
|
};
|
|
|
|
reader.onerror = function () {
|
|
|
|
reject(reader.error);
|
|
|
|
};
|
|
|
|
|
|
|
|
// Read the file as a text string
|
|
|
|
|
|
|
|
reader.readAsText(file);
|
|
|
|
});
|
|
|
|
const {
|
|
|
|
fileType,
|
|
|
|
appVersion: fileVersion,
|
2025-03-18 14:16:12 +01:00
|
|
|
signedPayload,
|
|
|
|
key,
|
2025-03-17 11:50:13 +01:00
|
|
|
} = JSON.parse(content);
|
|
|
|
if (fileType !== "B71-save-file")
|
|
|
|
throw new Error("Not a B71 save file");
|
|
|
|
if (fileVersion > appVersion)
|
|
|
|
throw new Error(
|
|
|
|
"Please update your app first, this file is for version " +
|
|
|
|
fileVersion +
|
|
|
|
" or newer.",
|
|
|
|
);
|
2025-03-17 19:02:19 +01:00
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
if (
|
|
|
|
key !==
|
|
|
|
hashCode(
|
|
|
|
"Security by obscurity, but really the game is oss so eh" +
|
|
|
|
signedPayload,
|
|
|
|
)
|
|
|
|
) {
|
|
|
|
throw new Error("Key does not match content.");
|
2025-03-17 19:02:19 +01:00
|
|
|
}
|
|
|
|
|
2025-03-18 14:16:12 +01:00
|
|
|
const localStorageContent = JSON.parse(signedPayload);
|
2025-03-17 11:50:13 +01:00
|
|
|
localStorage.clear();
|
|
|
|
for (let key in localStorageContent) {
|
|
|
|
localStorage.setItem(key, localStorageContent[key]);
|
|
|
|
}
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.save_file_loaded"),
|
|
|
|
text: t("main_menu.save_file_loaded_help"),
|
|
|
|
actions: [{ text: t("main_menu.save_file_loaded_ok") }],
|
|
|
|
});
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
} catch (e: any) {
|
|
|
|
await asyncAlert({
|
|
|
|
title: t("main_menu.save_file_error"),
|
|
|
|
text: e.message,
|
|
|
|
actions: [{ text: t("main_menu.save_file_loaded_ok") }],
|
|
|
|
});
|
|
|
|
}
|
|
|
|
input.value = "";
|
|
|
|
});
|
|
|
|
document.body.appendChild(input);
|
|
|
|
}
|
|
|
|
document.getElementById("save_file_picker")?.click();
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
2025-03-16 17:45:29 +01:00
|
|
|
actions.push({
|
|
|
|
text: t("main_menu.language"),
|
|
|
|
help: t("main_menu.language_help"),
|
|
|
|
async value() {
|
|
|
|
const pick = await asyncAlert({
|
|
|
|
title: t("main_menu.language"),
|
|
|
|
text: t("main_menu.language_help"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
text: "English",
|
|
|
|
value: "en",
|
|
|
|
},
|
|
|
|
{
|
|
|
|
text: "Français",
|
|
|
|
value: "fr",
|
|
|
|
},
|
|
|
|
],
|
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (pick && pick !== getCurrentLang() && (await confirmRestart())) {
|
|
|
|
setSettingValue("lang", pick);
|
|
|
|
window.location.reload();
|
|
|
|
}
|
|
|
|
},
|
|
|
|
});
|
|
|
|
|
|
|
|
const cb = await asyncAlert<() => void>({
|
|
|
|
title: t("main_menu.title"),
|
|
|
|
text: ``,
|
|
|
|
allowClose: true,
|
|
|
|
actions,
|
|
|
|
textAfterButtons: t("main_menu.footer_html", { appVersion }),
|
|
|
|
});
|
|
|
|
if (cb) {
|
|
|
|
cb();
|
2025-03-16 20:11:27 +01:00
|
|
|
gameState.needsRender = true;
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-03-06 16:46:25 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
async function openUnlocksList() {
|
2025-03-16 17:45:29 +01:00
|
|
|
const ts = getTotalScore();
|
|
|
|
const actions = [
|
|
|
|
...upgrades
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map(({ name, id, threshold, icon, fullHelp }) => ({
|
|
|
|
text: name,
|
|
|
|
help:
|
|
|
|
ts >= threshold ? fullHelp : t("unlocks.unlocks_at", { threshold }),
|
|
|
|
disabled: ts < threshold,
|
|
|
|
value: { perks: { [id]: 1 } } as RunParams,
|
|
|
|
icon,
|
|
|
|
})),
|
|
|
|
...allLevels
|
|
|
|
.sort((a, b) => a.threshold - b.threshold)
|
|
|
|
.map((l) => {
|
|
|
|
const available = ts >= l.threshold;
|
|
|
|
return {
|
|
|
|
text: l.name,
|
|
|
|
help: available
|
|
|
|
? t("unlocks.level_description", {
|
|
|
|
size: l.size,
|
|
|
|
bricks: l.bricks.filter((i) => i).length,
|
|
|
|
})
|
|
|
|
: t("unlocks.unlocks_at", { threshold: l.threshold }),
|
|
|
|
disabled: !available,
|
|
|
|
value: { level: l.name } as RunParams,
|
|
|
|
icon: icons[l.name],
|
|
|
|
};
|
|
|
|
}),
|
|
|
|
];
|
|
|
|
|
|
|
|
const percentUnlock = Math.round(
|
|
|
|
(actions.filter((a) => !a.disabled).length / actions.length) * 100,
|
|
|
|
);
|
|
|
|
const tryOn = await asyncAlert<RunParams>({
|
|
|
|
title: t("unlocks.title", { percentUnlock }),
|
|
|
|
text: `<p>${t("unlocks.intro", { ts })}
|
|
|
|
${percentUnlock < 100 ? t("unlocks.greyed_out_help") : ""}</p>
|
2025-03-06 16:46:25 +01:00
|
|
|
`,
|
2025-03-16 17:45:29 +01:00
|
|
|
textAfterButtons: `<p>
|
2025-03-14 11:59:49 +01:00
|
|
|
Your high score is ${gameState.highScore}.
|
2025-03-06 16:46:25 +01:00
|
|
|
Click an item above to start a run with it.
|
|
|
|
</p>`,
|
2025-03-16 17:45:29 +01:00
|
|
|
actions,
|
|
|
|
allowClose: true,
|
|
|
|
});
|
|
|
|
if (tryOn) {
|
|
|
|
if (await confirmRestart()) {
|
|
|
|
restart(tryOn);
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
}
|
2025-02-15 19:21:00 +01:00
|
|
|
}
|
|
|
|
|
2025-03-16 10:24:46 +01:00
|
|
|
export async function confirmRestart() {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (!gameState.currentLevel) return true;
|
|
|
|
|
|
|
|
return asyncAlert({
|
|
|
|
title: t("confirmRestart.title"),
|
|
|
|
text: t("confirmRestart.text"),
|
|
|
|
actions: [
|
|
|
|
{
|
|
|
|
value: true,
|
|
|
|
text: t("confirmRestart.yes"),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
value: false,
|
|
|
|
text: t("confirmRestart.no"),
|
|
|
|
},
|
|
|
|
],
|
|
|
|
});
|
2025-03-16 10:24:46 +01:00
|
|
|
}
|
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function toggleFullScreen() {
|
2025-03-16 17:45:29 +01:00
|
|
|
try {
|
|
|
|
if (document.fullscreenElement !== null) {
|
|
|
|
if (document.exitFullscreen) {
|
|
|
|
document.exitFullscreen();
|
|
|
|
} else if (document.webkitCancelFullScreen) {
|
|
|
|
document.webkitCancelFullScreen();
|
|
|
|
}
|
|
|
|
} else {
|
|
|
|
const docel = document.documentElement;
|
|
|
|
if (docel.requestFullscreen) {
|
|
|
|
docel.requestFullscreen();
|
|
|
|
} else if (docel.webkitRequestFullscreen) {
|
|
|
|
docel.webkitRequestFullscreen();
|
|
|
|
}
|
2025-03-01 14:20:27 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} catch (e) {
|
|
|
|
console.warn(e);
|
|
|
|
}
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
|
|
|
|
2025-03-11 13:56:42 +01:00
|
|
|
const pressed: { [k: string]: number } = {
|
2025-03-16 17:45:29 +01:00
|
|
|
ArrowLeft: 0,
|
|
|
|
ArrowRight: 0,
|
|
|
|
Shift: 0,
|
2025-03-06 16:46:25 +01:00
|
|
|
};
|
2025-03-01 14:20:27 +01:00
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
export function setKeyPressed(key: string, on: 0 | 1) {
|
2025-03-16 17:45:29 +01:00
|
|
|
pressed[key] = on;
|
|
|
|
gameState.keyboardPuckSpeed =
|
|
|
|
((pressed.ArrowRight - pressed.ArrowLeft) *
|
|
|
|
(1 + pressed.Shift * 2) *
|
|
|
|
gameState.gameZoneWidth) /
|
|
|
|
50;
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-03-01 14:20:27 +01:00
|
|
|
|
2025-03-06 16:46:25 +01:00
|
|
|
document.addEventListener("keydown", (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) {
|
|
|
|
toggleFullScreen();
|
|
|
|
} else if (e.key in pressed) {
|
|
|
|
setKeyPressed(e.key, 1);
|
|
|
|
}
|
|
|
|
if (e.key === " " && !alertsOpen) {
|
|
|
|
if (gameState.running) {
|
|
|
|
pause(true);
|
2025-03-01 14:20:27 +01:00
|
|
|
} else {
|
2025-03-16 17:45:29 +01:00
|
|
|
play();
|
2025-02-27 18:56:04 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-27 18:56:04 +01:00
|
|
|
|
2025-03-14 15:49:04 +01:00
|
|
|
document.addEventListener("keyup", async (e) => {
|
2025-03-16 17:45:29 +01:00
|
|
|
const focused = document.querySelector("button:focus");
|
|
|
|
if (e.key in pressed) {
|
|
|
|
setKeyPressed(e.key, 0);
|
|
|
|
} else if (
|
|
|
|
e.key === "ArrowDown" &&
|
|
|
|
focused?.nextElementSibling?.tagName === "BUTTON"
|
|
|
|
) {
|
|
|
|
(focused?.nextElementSibling as HTMLButtonElement)?.focus();
|
|
|
|
} else if (
|
|
|
|
e.key === "ArrowUp" &&
|
|
|
|
focused?.previousElementSibling?.tagName === "BUTTON"
|
|
|
|
) {
|
|
|
|
(focused?.previousElementSibling as HTMLButtonElement)?.focus();
|
|
|
|
} else if (e.key === "Escape" && closeModal) {
|
|
|
|
closeModal();
|
|
|
|
} else if (e.key === "Escape" && gameState.running) {
|
|
|
|
pause(true);
|
|
|
|
} else if (e.key.toLowerCase() === "m" && !alertsOpen) {
|
|
|
|
openSettingsPanel().then();
|
|
|
|
} else if (e.key.toLowerCase() === "s" && !alertsOpen) {
|
|
|
|
openScorePanel().then();
|
|
|
|
} else if (e.key.toLowerCase() === "r" && !alertsOpen) {
|
|
|
|
if (await confirmRestart()) {
|
|
|
|
restart({ levelToAvoid: currentLevelInfo(gameState).name });
|
2025-03-15 21:29:38 +01:00
|
|
|
}
|
2025-03-16 17:45:29 +01:00
|
|
|
} else {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
e.preventDefault();
|
2025-03-06 16:46:25 +01:00
|
|
|
});
|
2025-02-23 21:17:22 +01:00
|
|
|
|
2025-03-15 10:34:01 +01:00
|
|
|
export const gameState = newGameState({});
|
2025-03-14 11:59:49 +01:00
|
|
|
|
2025-03-14 12:23:19 +01:00
|
|
|
export function restart(params: RunParams) {
|
2025-03-16 17:45:29 +01:00
|
|
|
Object.assign(gameState, newGameState(params));
|
|
|
|
pauseRecording();
|
|
|
|
setLevel(gameState, 0);
|
2025-03-14 12:23:19 +01:00
|
|
|
}
|
|
|
|
|
2025-03-14 11:59:49 +01:00
|
|
|
restart({});
|
2025-03-06 16:46:25 +01:00
|
|
|
fitSize();
|
|
|
|
tick();
|
2025-03-15 21:29:38 +01:00
|
|
|
|
|
|
|
// @ts-ignore
|
|
|
|
// window.stressTest= ()=>restart({level:'Shark',perks:{base_combo:100, pierce:10, multiball:8}})
|
2025-03-16 17:45:29 +01:00
|
|
|
window.stressTest = () =>
|
2025-03-18 14:16:12 +01:00
|
|
|
restart({ level: "Bird", perks: { sapper: 2, pierce: 10, multiball: 3 } });
|