From a1bf54af717984c9aea8440c47a2cdc4c5b10dba Mon Sep 17 00:00:00 2001 From: Renan LE CARO Date: Sun, 16 Mar 2025 14:29:14 +0100 Subject: [PATCH] Updated soft reset perk to just keep lvl*10 % of combo --- Help.md | 10 +- Readme.md | 11 + dist/index.html | 3522 +++++++++++++++++++------------------- src/asyncAlert.ts | 122 ++ src/combo.ts | 69 - src/game.ts | 2539 +-------------------------- src/gameOver.ts | 251 +++ src/gameStateMutators.ts | 1178 +++++++++++++ src/game_utils.ts | 79 +- src/i18n/en.json | 16 +- src/i18n/fr.json | 8 +- src/newGameState.ts | 104 ++ src/options.ts | 99 +- src/rawUpgrades.ts | 4 +- src/recording.ts | 168 ++ src/render.ts | 717 ++++++++ src/resetBalls.ts | 61 - src/settings.ts | 16 +- src/sounds.ts | 1 + src/types.d.ts | 13 +- 20 files changed, 4543 insertions(+), 4445 deletions(-) create mode 100644 src/asyncAlert.ts delete mode 100644 src/combo.ts create mode 100644 src/gameOver.ts create mode 100644 src/gameStateMutators.ts create mode 100644 src/newGameState.ts create mode 100644 src/recording.ts create mode 100644 src/render.ts delete mode 100644 src/resetBalls.ts diff --git a/Help.md b/Help.md index 785580f..7506d08 100644 --- a/Help.md +++ b/Help.md @@ -86,15 +86,7 @@ Whenever your combo resets, it only looses half of its value. However, whenever it should increase, it has 50% chance of staying the same. If you pick it a second time, the effect is more pronounced : the combo keeps 66% of its value on reset, but only grows 33% of the time. If you have many perks that grow the combo every time a brick breaks, then it will still grow every time just slower. - -# Longer runs - -The default run lasts 7 levels. The selection process is to pick those levels at random, then sort them (more or less) by -number of bricks present, so that runs start with smaller levels and the bigger ones are left for the end. You can extend -the run by picking up to three times the "+1 level" upgrade. - -"Sturdy bricks" and "Respawn" can also extend the game time significantly. - + # Aiming What decides how the ball flies away is only the position of the puck hit. If the ball hits the puck dead center, it will diff --git a/Readme.md b/Readme.md index 42cc818..0d8f9f6 100644 --- a/Readme.md +++ b/Readme.md @@ -20,14 +20,23 @@ It's very lean and does not take much storage space (Roughly 0.1MB). If the app stutters, turn on "fast mode" in the settings to render a simplified view that should be faster. There's also an easy mode for kids (slower ball). + # Next +- extract game tick function to be able to test it +- extract sound logic, only set the params as a gamestate object - separate particles by type - reuse coins and particles - sturdy bricks map of remaining hits +# bugs + +* [colin] parfois je dois appuyer plusieurs fois sur "Start a new run" pour vraiment commencer une nouvelle partie. dans ce cas, lhécran de jeu derrière se "désassombrit" comme si le jeu avait démarré plusieurs parties en même temps. +* [colin] lorsque le puck est trop petit, l'affichage du combo disparaît. mais c'est peut-être volontaire pour qu'il ne dépasse pas du puck ? afficher simplement le chiffre serait suffisant et tiendrait dans le puck +* [colin] le niveau bug parfois et ne peux pas démarrer. dans ce cas, la balle apparait comme démarrant sans être attachée au puck, comme si la partie avait déjà commencée. il faut redémarrer B71 pour que ça fonctionne # UX +- instead of the free perk at level one, offer to skip lvl 1 and directly pick 4 perks, but only if you manage to clear lvl 1 with 4 upgrades. - the onboarding feels weird, missing a tutorial - It's a bit confusing at first to grasp that one upgrade is applied randomly at the start of the game - on mobile, add an element that feels like it can be "grabbed" and make it shine while writing "Push here to play" @@ -143,6 +152,7 @@ There's also an easy mode for kids (slower ball). - colored coins only (coins should be of the color of the ball to count ) - level flips horizontally every time a ball bounces on puck - coins that hit the puck disappear, missed ones are scored +- squirell : keep coins on screen to have a higher combo # extra levels @@ -170,6 +180,7 @@ Possible challenges : - other perks can be randomly turned off - ball keeps accelerating until unplayable - graphical effects like trail, contrast, blur to make it harder to see what's going on + - ball creates a draft behind itself that blows coins in odd patterns # extend re-playability - hard mode : bricks take many hits, perks more rare, missing clears level score, missing coins deducts score.. diff --git a/dist/index.html b/dist/index.html index a294fc7..3e6c187 100644 --- a/dist/index.html +++ b/dist/index.html @@ -578,20 +578,7 @@ parcelHelpers.defineInteropFlag(exports); parcelHelpers.export(exports, "play", ()=>play); parcelHelpers.export(exports, "pause", ()=>pause); parcelHelpers.export(exports, "fitSize", ()=>fitSize); -parcelHelpers.export(exports, "recomputeTargetBaseSpeed", ()=>recomputeTargetBaseSpeed); -parcelHelpers.export(exports, "brickCenterX", ()=>brickCenterX); -parcelHelpers.export(exports, "brickCenterY", ()=>brickCenterY); -parcelHelpers.export(exports, "getRowColIndex", ()=>getRowColIndex); -parcelHelpers.export(exports, "spawnExplosion", ()=>spawnExplosion); -parcelHelpers.export(exports, "addToScore", ()=>addToScore); -parcelHelpers.export(exports, "pickedUpgradesHTMl", ()=>pickedUpgradesHTMl); -parcelHelpers.export(exports, "setLevel", ()=>setLevel); -parcelHelpers.export(exports, "currentLevelInfo", ()=>currentLevelInfo); -parcelHelpers.export(exports, "getPossibleUpgrades", ()=>getPossibleUpgrades); -parcelHelpers.export(exports, "getUpgraderUnlockPoints", ()=>getUpgraderUnlockPoints); -parcelHelpers.export(exports, "dontOfferTooSoon", ()=>dontOfferTooSoon); -parcelHelpers.export(exports, "pickRandomUpgrades", ()=>pickRandomUpgrades); -parcelHelpers.export(exports, "setMousePos", ()=>setMousePos); +parcelHelpers.export(exports, "openUpgradesPicker", ()=>openUpgradesPicker); parcelHelpers.export(exports, "brickIndex", ()=>brickIndex); parcelHelpers.export(exports, "hasBrick", ()=>hasBrick); parcelHelpers.export(exports, "hitsSomething", ()=>hitsSomething); @@ -599,68 +586,32 @@ parcelHelpers.export(exports, "shouldPierceByColor", ()=>shouldPierceByColor); parcelHelpers.export(exports, "coinBrickHitCheck", ()=>coinBrickHitCheck); parcelHelpers.export(exports, "bordersHitCheck", ()=>bordersHitCheck); parcelHelpers.export(exports, "tick", ()=>tick); -parcelHelpers.export(exports, "isTelekinesisActive", ()=>isTelekinesisActive); -parcelHelpers.export(exports, "ballTick", ()=>ballTick); -parcelHelpers.export(exports, "getTotalScore", ()=>getTotalScore); -parcelHelpers.export(exports, "addToTotalScore", ()=>addToTotalScore); -parcelHelpers.export(exports, "addToTotalPlayTime", ()=>addToTotalPlayTime); -parcelHelpers.export(exports, "gameOver", ()=>gameOver); -parcelHelpers.export(exports, "getHistograms", ()=>getHistograms); -parcelHelpers.export(exports, "explodeBrick", ()=>explodeBrick); -parcelHelpers.export(exports, "max_levels", ()=>max_levels); -parcelHelpers.export(exports, "render", ()=>render); -parcelHelpers.export(exports, "renderAllBricks", ()=>renderAllBricks); -parcelHelpers.export(exports, "drawPuck", ()=>drawPuck); -parcelHelpers.export(exports, "drawBall", ()=>drawBall); -parcelHelpers.export(exports, "drawCoin", ()=>drawCoin); -parcelHelpers.export(exports, "drawFuzzyBall", ()=>drawFuzzyBall); -parcelHelpers.export(exports, "drawBrick", ()=>drawBrick); -parcelHelpers.export(exports, "roundRect", ()=>roundRect); -parcelHelpers.export(exports, "drawIMG", ()=>drawIMG); -parcelHelpers.export(exports, "drawText", ()=>drawText); -parcelHelpers.export(exports, "asyncAlert", ()=>asyncAlert); parcelHelpers.export(exports, "confirmRestart", ()=>confirmRestart); -parcelHelpers.export(exports, "distance2", ()=>distance2); -parcelHelpers.export(exports, "distanceBetween", ()=>distanceBetween); -parcelHelpers.export(exports, "rainbowColor", ()=>rainbowColor); -parcelHelpers.export(exports, "repulse", ()=>repulse); -parcelHelpers.export(exports, "attract", ()=>attract); -parcelHelpers.export(exports, "recordOneFrame", ()=>recordOneFrame); -parcelHelpers.export(exports, "drawMainCanvasOnSmallCanvas", ()=>drawMainCanvasOnSmallCanvas); -parcelHelpers.export(exports, "startRecordingGame", ()=>startRecordingGame); -parcelHelpers.export(exports, "pauseRecording", ()=>pauseRecording); -parcelHelpers.export(exports, "resumeRecording", ()=>resumeRecording); -parcelHelpers.export(exports, "stopRecording", ()=>stopRecording); -parcelHelpers.export(exports, "captureFileName", ()=>captureFileName); -parcelHelpers.export(exports, "findLast", ()=>findLast); parcelHelpers.export(exports, "toggleFullScreen", ()=>toggleFullScreen); parcelHelpers.export(exports, "setKeyPressed", ()=>setKeyPressed); -parcelHelpers.export(exports, "newGameState", ()=>newGameState); parcelHelpers.export(exports, "gameState", ()=>gameState); parcelHelpers.export(exports, "restart", ()=>restart); var _loadGameData = require("./loadGameData"); -var _options = require("./options"); var _sounds = require("./sounds"); -var _resetBalls = require("./resetBalls"); var _gameUtils = require("./game_utils"); -var _combo = require("./combo"); var _swLoader = require("./sw_loader"); var _i18N = require("./i18n/i18n"); var _settings = require("./settings"); -const gameCanvas = document.getElementById("game"); -const ctx = gameCanvas.getContext("2d", { - alpha: false -}); -const bombSVG = document.createElement("img"); -bombSVG.src = "data:image/svg+xml;base64," + btoa(` +var _gameStateMutators = require("./gameStateMutators"); +var _render = require("./render"); +var _recording = require("./recording"); +var _newGameState = require("./newGameState"); +var _asyncAlert = require("./asyncAlert"); +var _options = require("./options"); +(0, _render.bombSVG).src = "data:image/svg+xml;base64," + btoa(` `); function play() { if (gameState.running) return; gameState.running = true; - startRecordingGame(); + (0, _recording.startRecordingGame)(gameState); (0, _sounds.getAudioContext)()?.resume(); - resumeRecording(); + (0, _recording.resumeRecording)(); document.body.className = gameState.running ? " running " : " paused "; } function pause(playerAskedForPause) { @@ -668,35 +619,29 @@ function pause(playerAskedForPause) { if (gameState.pauseTimeout) return; gameState.pauseTimeout = setTimeout(()=>{ gameState.running = false; - gameState.needsRender = true; setTimeout(()=>{ if (!gameState.running) (0, _sounds.getAudioContext)()?.suspend(); }, 1000); - pauseRecording(); + (0, _recording.pauseRecording)(); gameState.pauseTimeout = null; document.body.className = gameState.running ? " running " : " paused "; - scoreDisplay.className = ""; + (0, _render.scoreDisplay).className = ""; }, 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(); } -const background = document.createElement("img"); -const backgroundCanvas = document.createElement("canvas"); -background.addEventListener("load", ()=>{ - gameState.needsRender = true; -}); const fitSize = ()=>{ - const { width, height } = gameCanvas.getBoundingClientRect(); + const { width, height } = (0, _render.gameCanvas).getBoundingClientRect(); gameState.canvasWidth = width; gameState.canvasHeight = height; - gameCanvas.width = width; - gameCanvas.height = height; - ctx.fillStyle = currentLevelInfo()?.color || "black"; - ctx.globalAlpha = 1; - ctx.fillRect(0, 0, width, height); - backgroundCanvas.width = width; - backgroundCanvas.height = height; + (0, _render.gameCanvas).width = width; + (0, _render.gameCanvas).height = height; + (0, _render.ctx).fillStyle = (0, _gameUtils.currentLevelInfo)(gameState)?.color || "black"; + (0, _render.ctx).globalAlpha = 1; + (0, _render.ctx).fillRect(0, 0, width, height); + (0, _render.backgroundCanvas).width = width; + (0, _render.backgroundCanvas).height = height; gameState.gameZoneHeight = (0, _options.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; @@ -705,13 +650,13 @@ const fitSize = ()=>{ gameState.offsetXRoundedDown = gameState.offsetX; if (gameState.offsetX < gameState.ballSize) gameState.offsetXRoundedDown = 0; gameState.gameZoneWidthRoundedUp = width - 2 * gameState.offsetXRoundedDown; - backgroundCanvas.title = "resized"; + (0, _render.backgroundCanvas).title = "resized"; // Ensure puck stays within bounds - setMousePos(gameState.puckPosition); + (0, _gameStateMutators.setMousePos)(gameState, gameState.puckPosition); gameState.coins = []; gameState.flashes = []; pause(true); - (0, _resetBalls.putBallsAtPuck)(gameState); + (0, _gameStateMutators.putBallsAtPuck)(gameState); // For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ document.documentElement.style.setProperty("--vh", `${window.innerHeight * 0.01}px`); }; @@ -719,73 +664,10 @@ window.addEventListener("resize", fitSize); window.addEventListener("fullscreenchange", fitSize); setInterval(()=>{ // Sometimes, the page changes size without triggering the event (when switching to fullscreen, closing debug panel...) - const { width, height } = gameCanvas.getBoundingClientRect(); + const { width, height } = (0, _render.gameCanvas).getBoundingClientRect(); if (width !== gameState.canvasWidth || height !== gameState.canvasHeight) fitSize(); }, 1000); -function recomputeTargetBaseSpeed() { - // We never want the ball to completely stop, it will move at least 3px per frame - gameState.baseSpeed = Math.max(3, gameState.gameZoneWidth / 12 / 10 + gameState.currentLevel / 3 + gameState.levelTime / 30000 - gameState.perks.slow_down * 2); -} -function brickCenterX(index) { - return gameState.offsetX + (index % gameState.gridSize + 0.5) * gameState.brickWidth; -} -function brickCenterY(index) { - return (Math.floor(index / gameState.gridSize) + 0.5) * gameState.brickWidth; -} -function getRowColIndex(row, col) { - if (row < 0 || col < 0 || row >= gameState.gridSize || col >= gameState.gridSize) return -1; - return row * gameState.gridSize + col; -} -function spawnExplosion(count, x, y, color, duration = 150, size = gameState.coinSize) { - if (!!(0, _options.isOptionOn)("basic")) return; - if (gameState.flashes.length > gameState.MAX_PARTICLES) // Avoid freezing when lots of explosion happen at once - count = 1; - for(let i = 0; i < count; i++)gameState.flashes.push({ - type: "particle", - time: gameState.levelTime, - size, - x: x + (Math.random() - 0.5) * gameState.brickWidth / 2, - y: y + (Math.random() - 0.5) * gameState.brickWidth / 2, - vx: (Math.random() - 0.5) * 30, - vy: (Math.random() - 0.5) * 30, - color, - duration, - ethereal: false - }); -} -function addToScore(coin) { - coin.destroyed = true; - gameState.score += coin.points; - gameState.lastScoreIncrease = gameState.levelTime; - addToTotalScore(coin.points); - if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { - gameState.highScore = gameState.score; - localStorage.setItem("breakout-3-hs", gameState.score.toString()); - } - if (!(0, _options.isOptionOn)("basic")) gameState.flashes.push({ - type: "particle", - duration: 100 + Math.random() * 50, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: coin.color, - x: coin.previousX, - y: coin.previousY, - vx: (gameState.canvasWidth - coin.x) / 100, - vy: -coin.y / 100, - ethereal: true - }); - if (Date.now() - gameState.lastPlayedCoinGrab > 16) { - gameState.lastPlayedCoinGrab = Date.now(); - (0, _sounds.sounds).coinCatch(coin.x); - } - gameState.runStatistics.score += coin.points; -} -function pickedUpgradesHTMl() { - let list = ""; - for (let u of (0, _loadGameData.upgrades))for(let i = 0; i < gameState.perks[u.id]; i++)list += (0, _loadGameData.icons)["icon:" + u.id] + " "; - return list; -} -async function openUpgradesPicker() { +async function openUpgradesPicker(gameState) { const catchRate = (gameState.score - gameState.levelStartScore) / (gameState.levelSpawnedCoins || 1); let repeats = 1; let choices = 3; @@ -815,19 +697,19 @@ async function openUpgradesPicker() { missesGain = (0, _i18N.t)('level_up.plus_one_choice'); } while(repeats--){ - const actions = pickRandomUpgrades(choices + gameState.perks.one_more_choice - gameState.perks.instant_upgrade); + const actions = (0, _gameStateMutators.pickRandomUpgrades)(gameState, choices + gameState.perks.one_more_choice - gameState.perks.instant_upgrade); if (!actions.length) break; let textAfterButtons = `

${(0, _i18N.t)('level_up.after_buttons', { level: gameState.currentLevel + 1, - max: max_levels() + max: (0, _gameUtils.max_levels)(gameState) })}

-

${pickedUpgradesHTMl()}

+

${(0, _gameUtils.pickedUpgradesHTMl)(gameState)}

`; const compliment = timeGain && catchGain && missesGain && (0, _i18N.t)("level_up.compliment_perfect") || (timeGain || catchGain || missesGain) && (0, _i18N.t)("level_up.compliment_good") || (0, _i18N.t)('level_up.compliment_advice'); - const upgradeId = await asyncAlert({ + const upgradeId = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('level_up.pick_upgrade_title') + (repeats ? " (" + (repeats + 1) + ")" : ""), actions, text: `

${(0, _i18N.t)('level_up.before_buttons', { @@ -848,121 +730,41 @@ async function openUpgradesPicker() { if (upgradeId === "instant_upgrade") repeats += 2; gameState.runStatistics.upgrades_picked++; } - (0, _combo.resetCombo)(gameState, undefined, undefined); - (0, _resetBalls.resetBalls)(gameState); + (0, _gameStateMutators.resetCombo)(gameState, undefined, undefined); + (0, _gameStateMutators.resetBalls)(gameState); } -function setLevel(l) { - stopRecording(); - pause(false); - if (l > 0) openUpgradesPicker(); - gameState.currentLevel = l; - gameState.levelTime = 0; - gameState.autoCleanUses = 0; - gameState.lastTickDown = gameState.levelTime; - gameState.levelStartScore = gameState.score; - gameState.levelSpawnedCoins = 0; - gameState.levelMisses = 0; - gameState.runStatistics.levelsPlayed++; - (0, _combo.resetCombo)(gameState, undefined, undefined); - recomputeTargetBaseSpeed(); - (0, _resetBalls.resetBalls)(gameState); - const lvl = currentLevelInfo(); - if (lvl.size !== gameState.gridSize) { - gameState.gridSize = lvl.size; - fitSize(); - } - gameState.coins = []; - gameState.bricks = [ - ...lvl.bricks - ]; - gameState.flashes = []; - // 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 currentLevelInfo() { - return gameState.runLevels[gameState.currentLevel % gameState.runLevels.length]; -} -function getPossibleUpgrades(gameState) { - return (0, _loadGameData.upgrades).filter((u)=>gameState.totalScoreAtRunStart >= u.threshold).filter((u)=>!u?.requires || gameState.perks[u?.requires]); -} -function getUpgraderUnlockPoints() { - let list = []; - (0, _loadGameData.upgrades).forEach((u)=>{ - if (u.threshold) list.push({ - threshold: u.threshold, - title: u.name + ' ' + (0, _i18N.t)('level_up.unlocked_perk') - }); - }); - (0, _loadGameData.allLevels).forEach((l)=>{ - list.push({ - threshold: l.threshold, - title: l.name + ' ' + (0, _i18N.t)('level_up.unlocked_level') - }); - }); - return list.filter((o)=>o.threshold).sort((a, b)=>a.threshold - b.threshold); -} -function dontOfferTooSoon(gameState, id) { - gameState.lastOffered[id] = Math.round(Date.now() / 1000); -} -function pickRandomUpgrades(count) { - 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).slice(0, count).sort((a, b)=>a.id > b.id ? 1 : -1); - list.forEach((u)=>{ - dontOfferTooSoon(gameState, u.id); - }); - return list.map((u)=>({ - text: u.name + (gameState.perks[u.id] ? (0, _i18N.t)('level_up.upgrade_perk_to_level', { - level: gameState.perks[u.id] + 1 - }) : ""), - icon: (0, _loadGameData.icons)["icon:" + u.id], - value: u.id, - help: u.help(gameState.perks[u.id] + 1) - })); -} -function setMousePos(x) { - gameState.needsRender = true; - gameState.puckPosition = x; - // We have borders visible, enforce them - if (gameState.puckPosition < gameState.offsetXRoundedDown + gameState.puckWidth / 2) gameState.puckPosition = gameState.offsetXRoundedDown + gameState.puckWidth / 2; - if (gameState.puckPosition > gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp - gameState.puckWidth / 2) gameState.puckPosition = gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp - gameState.puckWidth / 2; - if (!gameState.running && !gameState.levelTime) (0, _resetBalls.putBallsAtPuck)(gameState); -} -gameCanvas.addEventListener("mouseup", (e)=>{ +(0, _render.gameCanvas).addEventListener("mouseup", (e)=>{ if (e.button !== 0) return; if (gameState.running) pause(true); else { play(); - if ((0, _options.isOptionOn)("pointerLock")) gameCanvas.requestPointerLock(); + if ((0, _options.isOptionOn)("pointerLock")) (0, _render.gameCanvas).requestPointerLock(); } }); -gameCanvas.addEventListener("mousemove", (e)=>{ - if (document.pointerLockElement === gameCanvas) setMousePos(gameState.puckPosition + e.movementX); - else setMousePos(e.x); +(0, _render.gameCanvas).addEventListener("mousemove", (e)=>{ + if (document.pointerLockElement === (0, _render.gameCanvas)) (0, _gameStateMutators.setMousePos)(gameState, gameState.puckPosition + e.movementX); + else (0, _gameStateMutators.setMousePos)(gameState, e.x); }); -gameCanvas.addEventListener("touchstart", (e)=>{ +(0, _render.gameCanvas).addEventListener("touchstart", (e)=>{ e.preventDefault(); if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + (0, _gameStateMutators.setMousePos)(gameState, e.touches[0].pageX); play(); }); -gameCanvas.addEventListener("touchend", (e)=>{ +(0, _render.gameCanvas).addEventListener("touchend", (e)=>{ e.preventDefault(); pause(true); }); -gameCanvas.addEventListener("touchcancel", (e)=>{ +(0, _render.gameCanvas).addEventListener("touchcancel", (e)=>{ e.preventDefault(); pause(true); - gameState.needsRender = true; }); -gameCanvas.addEventListener("touchmove", (e)=>{ +(0, _render.gameCanvas).addEventListener("touchmove", (e)=>{ if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + (0, _gameStateMutators.setMousePos)(gameState, e.touches[0].pageX); }); function brickIndex(x, y) { - return getRowColIndex(Math.floor(y / gameState.brickWidth), Math.floor((x - gameState.offsetX) / gameState.brickWidth)); + return (0, _gameUtils.getRowColIndex)(gameState, Math.floor(y / gameState.brickWidth), Math.floor((x - gameState.offsetX) / gameState.brickWidth)); } function hasBrick(index) { if (gameState.bricks[index]) return index; @@ -1037,1104 +839,25 @@ function bordersHitCheck(coin, radius, delta) { return hhit + vhit * 2; } function tick() { - recomputeTargetBaseSpeed(); const currentTick = performance.now(); - gameState.puckWidth = gameState.gameZoneWidth / 12 * (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); - if (gameState.keyboardPuckSpeed) setMousePos(gameState.puckPosition + gameState.keyboardPuckSpeed); - if (gameState.running) { - gameState.levelTime += currentTick - gameState.lastTick; - gameState.runStatistics.runTime += currentTick - gameState.lastTick; - gameState.runStatistics.max_combo = Math.max(gameState.runStatistics.max_combo, gameState.combo); - // How many times to compute - let delta = Math.min(4, (currentTick - gameState.lastTick) / (1000 / 60)); - delta *= gameState.running ? 1 : 0; - gameState.coins = gameState.coins.filter((coin)=>!coin.destroyed); - 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; - (0, _combo.decreaseCombo)(gameState, gameState.perks.hot_start, gameState.puckPosition, gameState.gameZoneHeight - 2 * gameState.puckHeight); - } - if (remainingBricks <= gameState.perks.skip_last && !gameState.autoCleanUses) { - gameState.bricks.forEach((type, index)=>{ - if (type) explodeBrick(index, gameState.balls[0], true); - }); - gameState.autoCleanUses++; - } - if (!remainingBricks && !gameState.coins.length) { - if (gameState.currentLevel + 1 < max_levels()) setLevel(gameState.currentLevel + 1); - else gameOver((0, _i18N.t)('gameOver.win.title'), (0, _i18N.t)('gameOver.win.summary', { - score: gameState.score - })); - } else if (gameState.running || gameState.levelTime) { - let playedCoinBounce = false; - const coinRadius = Math.round(gameState.coinSize / 2); - gameState.coins.forEach((coin)=>{ - if (coin.destroyed) return; - if (gameState.perks.coin_magnet) { - const attractionX = delta * (gameState.puckPosition - coin.x) / (100 + Math.pow(coin.y - gameState.gameZoneHeight, 2) + Math.pow(coin.x - gameState.puckPosition, 2)) * gameState.perks.coin_magnet * 100; - coin.vx += attractionX; - coin.sa -= attractionX / 10; - } - const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * delta; - 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 - coin.vy += delta * coin.weight * 0.8; - const speed = Math.abs(coin.sx) + Math.abs(coin.sx); - const hitBorder = bordersHitCheck(coin, coin.size / 2, delta); - 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 - gameState.puckHeight) addToScore(coin); - else if (coin.y > gameState.canvasHeight + coinRadius) { - coin.destroyed = true; - if (gameState.perks.compound_interest) (0, _combo.resetCombo)(gameState, coin.x, coin.y); - } - const hitBrick = coinBrickHitCheck(coin); - if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { - if (gameState.bricks[hitBrick] && coin.color !== gameState.bricks[hitBrick] && gameState.bricks[hitBrick] !== "black" && !coin.coloredABrick) { - gameState.bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - (0, _sounds.sounds).colorChange(coin.x, 0.3); - } - } - if (typeof hitBrick !== "undefined" || hitBorder) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20 && !playedCoinBounce) { - playedCoinBounce = true; - (0, _sounds.sounds).coinBounce(coin.x, 0.2); - } - if (Math.abs(coin.vy) < 3) coin.vy = 0; - } - }); - gameState.balls.forEach((ball)=>ballTick(ball, delta)); - 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) gameState.flashes.push({ - type: "particle", - duration: 150, - ethereal: true, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - x: gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, - y: Math.random() * gameState.gameZoneHeight, - vx: windD * 8, - vy: 0 - }); - } - gameState.flashes.forEach((flash)=>{ - if (flash.type === "particle") { - flash.x += flash.vx * delta; - flash.y += flash.vy * delta; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) flash.destroyed = true; - } - } - }); - } - if (gameState.combo > (0, _combo.baseCombo)(gameState)) { - // The red should still be visible on a white bg - const baseParticle = !(0, _options.isOptionOn)("basic") && (gameState.combo - (0, _combo.baseCombo)(gameState)) * Math.random() > 5 && gameState.running && { - type: "particle", - duration: 100 * (Math.random() + 1), - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: "red", - ethereal: true - }; - if (gameState.perks.top_is_lava) baseParticle && gameState.flashes.push({ - ...baseParticle, - x: gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, - y: 0, - vx: (Math.random() - 0.5) * 10, - vy: 5 - }); - if (gameState.perks.left_is_lava && baseParticle) gameState.flashes.push({ - ...baseParticle, - x: gameState.offsetXRoundedDown, - y: Math.random() * gameState.gameZoneHeight, - vx: 5, - vy: (Math.random() - 0.5) * 10 - }); - if (gameState.perks.right_is_lava && baseParticle) gameState.flashes.push({ - ...baseParticle, - x: gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, - y: Math.random() * gameState.gameZoneHeight, - vx: -5, - vy: (Math.random() - 0.5) * 10 - }); - 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); - baseParticle && gameState.flashes.push({ - ...baseParticle, - x, - y: gameState.gameZoneHeight, - vx: (Math.random() - 0.5) * 10, - vy: -5 - }); - } - if (gameState.perks.streak_shots) { - const pos = 0.5 - Math.random(); - baseParticle && gameState.flashes.push({ - ...baseParticle, - duration: 100, - x: gameState.puckPosition + gameState.puckWidth * pos, - y: gameState.gameZoneHeight - gameState.puckHeight, - vx: pos * 10, - vy: -5 - }); - } - } - } - render(); - requestAnimationFrame(tick); + const timeDeltaMs = currentTick - gameState.lastTick; gameState.lastTick = currentTick; -} -function isTelekinesisActive(ball) { - return gameState.perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; -} -function ballTick(ball, delta) { - 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; - if (isTelekinesisActive(ball)) { - speedLimitDampener += 3; - ball.vx += (gameState.puckPosition - ball.x) / 1000 * delta * gameState.perks.telekinesis; + const frames = Math.min(4, timeDeltaMs / (1000 / 60)); + if (gameState.keyboardPuckSpeed) (0, _gameStateMutators.setMousePos)(gameState, gameState.puckPosition + gameState.keyboardPuckSpeed); + (0, _gameStateMutators.normalizeGameState)(gameState); + if (gameState.running) { + gameState.levelTime += timeDeltaMs; + gameState.runStatistics.runTime += timeDeltaMs; + (0, _gameStateMutators.gameStateTick)(gameState, frames); } - 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(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(ball, b2, gameState.perks.ball_attract_ball); - } - 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(ball, { - x: gameState.puckPosition, - y: gameState.gameZoneHeight - }, gameState.perks.puck_repulse_ball + 1, false); - if (gameState.perks.respawn && ball.hitItem?.length > 1 && !(0, _options.isOptionOn)("basic")) for(let i = 0; i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; i++){ - const { index, color } = ball.hitItem[i]; - if (gameState.bricks[index] || color === "black") continue; - const vertical = Math.random() > 0.5; - const dx = Math.random() > 0.5 ? 1 : -1; - const dy = Math.random() > 0.5 ? 1 : -1; - gameState.flashes.push({ - type: "particle", - duration: 250, - ethereal: true, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color, - x: brickCenterX(index) + dx * gameState.brickWidth / 2, - y: brickCenterY(index) + dy * gameState.brickWidth / 2, - vx: vertical ? 0 : -dx * gameState.baseSpeed, - vy: vertical ? -dy * gameState.baseSpeed : 0 - }); - } - const borderHitCode = bordersHitCheck(ball, gameState.ballSize / 2, delta); - if (borderHitCode) { - if (gameState.perks.left_is_lava && borderHitCode % 2 && ball.x < gameState.offsetX + gameState.gameZoneWidth / 2) (0, _combo.resetCombo)(gameState, ball.x, ball.y); - if (gameState.perks.right_is_lava && borderHitCode % 2 && ball.x > gameState.offsetX + gameState.gameZoneWidth / 2) (0, _combo.resetCombo)(gameState, ball.x, ball.y); - if (gameState.perks.top_is_lava && borderHitCode >= 2) (0, _combo.resetCombo)(gameState, ball.x, ball.y + gameState.ballSize); - (0, _sounds.sounds).wallBeep(ball.x); - ball.bouncesList?.push({ - x: ball.previousX, - y: ball.previousY - }); - } - // 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); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - (0, _sounds.sounds).wallBeep(ball.x); - } else { - ball.vy *= -1; - gameState.perks.extra_life = Math.max(0, gameState.perks.extra_life - 1); - (0, _sounds.sounds).lifeLost(ball.x); - if (!(0, _options.isOptionOn)("basic")) for(let i = 0; i < 10; i++)gameState.flashes.push({ - type: "particle", - ethereal: false, - color: "red", - destroyed: false, - duration: 150, - size: gameState.coinSize / 2, - time: gameState.levelTime, - x: ball.x, - y: ball.y, - vx: Math.random() * gameState.baseSpeed * 3, - vy: gameState.baseSpeed * 3 - }); - } - if (gameState.perks.streak_shots) (0, _combo.resetCombo)(gameState, ball.x, ball.y); - if (gameState.perks.respawn) ball.hitItem.slice(0, -1).slice(0, gameState.perks.respawn).forEach(({ index, color })=>{ - if (!gameState.bricks[index] && color !== "black") gameState.bricks[index] = color; - }); - ball.hitItem = []; - if (!ball.hitSinceBounce) { - gameState.runStatistics.misses++; - gameState.levelMisses++; - (0, _combo.resetCombo)(gameState, ball.x, ball.y); - gameState.flashes.push({ - type: "text", - text: (0, _i18N.t)('play.missed_ball'), - duration: 500, - time: gameState.levelTime, - size: gameState.puckHeight * 1.5, - color: "red", - x: gameState.puckPosition, - y: gameState.gameZoneHeight - gameState.puckHeight * 2 - }); - } - gameState.runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.sapperUses = 0; - ball.piercedSinceBounce = 0; - ball.bouncesList = [ - { - x: ball.previousX, - y: ball.previousY - } - ]; - } - if (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 && gameState.running) { - ball.destroyed = true; - gameState.runStatistics.balls_lost++; - if (!gameState.balls.find((b)=>!b.destroyed)) gameOver((0, _i18N.t)('gameOver.lost.title'), (0, _i18N.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; - let sturdyBounce = hitBrick && gameState.bricks[hitBrick] !== "black" && gameState.perks.sturdy_bricks && gameState.perks.sturdy_bricks > Math.random() * 5; - let pierce = false; - if (sturdyBounce || typeof hitBrick === "undefined") ; - else if (shouldPierceByColor(vhit, hhit, chit)) pierce = true; - else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { - pierce = true; - ball.piercedSinceBounce++; - } - 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 (sturdyBounce) { - (0, _sounds.sounds).wallBeep(x); - return; - } - if (typeof hitBrick !== "undefined") { - const initialBrickColor = gameState.bricks[hitBrick]; - explodeBrick(hitBrick, ball, false); - if (ball.sapperUses < gameState.perks.sapper && initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !gameState.bricks[hitBrick]) { - gameState.bricks[hitBrick] = "black"; - ball.sapperUses++; - } - } - if (!(0, _options.isOptionOn)("basic")) { - ball.sparks += delta * (gameState.combo - 1) / 30; - if (ball.sparks > 1) { - gameState.flashes.push({ - type: "particle", - duration: 100 * ball.sparks, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: gameState.ballsColor, - x: ball.x, - y: ball.y, - vx: (Math.random() - 0.5) * gameState.baseSpeed, - vy: (Math.random() - 0.5) * gameState.baseSpeed, - ethereal: false - }); - ball.sparks = 0; - } - } -} -function getTotalScore() { - try { - return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); - } catch (e) { - return 0; - } -} -function addToTotalScore(points) { - if (gameState.isCreativeModeRun) return; - try { - localStorage.setItem("breakout_71_total_score", JSON.stringify(getTotalScore() + points)); - } catch (e) {} -} -function addToTotalPlayTime(ms) { - try { - localStorage.setItem("breakout_71_total_play_time", JSON.stringify(JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + ms)); - } catch (e) {} -} -function gameOver(title, intro) { - if (!gameState.running) return; - pause(true); - stopRecording(); - addToTotalPlayTime(gameState.runStatistics.runTime); - gameState.runStatistics.max_level = gameState.currentLevel + 1; - let animationDelay = -300; - const getDelay = ()=>{ - animationDelay += 800; - return "animation-delay:" + animationDelay + "ms;"; - }; - // unlocks - let unlocksInfo = ""; - const endTs = getTotalScore(); - const startTs = endTs - gameState.score; - const list = getUpgraderUnlockPoints(); - list.filter((u)=>u.threshold > startTs && u.threshold < endTs).forEach((u)=>{ - unlocksInfo += ` -

- ${u.title} - -

-`; - }); - const previousUnlockAt = findLast(list, (u)=>u.threshold <= endTs)?.threshold || 0; - const nextUnlock = list.find((u)=>u.threshold > endTs); - if (nextUnlock) { - const total = nextUnlock?.threshold - previousUnlockAt; - const done = endTs - previousUnlockAt; - intro += (0, _i18N.t)('gameOver.next_unlock', { - points: nextUnlock.threshold - endTs - }); - const scaleX = (done / total).toFixed(2); - unlocksInfo += ` -

- ${nextUnlock.title} - -

- -`; - list.slice(list.indexOf(nextUnlock) + 1).slice(0, 3).forEach((u)=>{ - unlocksInfo += ` -

- ${u.title} -

-`; - }); - } - let unlockedItems = list.filter((u)=>u.threshold > startTs && u.threshold < endTs); - if (unlockedItems.length) unlocksInfo += `

${(0, _i18N.t)('gameOver.unlocked_count', { - count: unlockedItems.length - })} ${unlockedItems.map((u)=>u.title).join(", ")}

`; - // Avoid the sad sound right as we restart a new games - gameState.combo = 1; - asyncAlert({ - allowClose: true, - title, - text: ` - ${gameState.isCreativeModeRun ? `

${(0, _i18N.t)('gameOver.test_run')}

` : ""} -

${intro}

-

${(0, _i18N.t)('gameOver.cumulative_total', { - startTs, - endTs - })}

- ${unlocksInfo} - `, - actions: [ - { - value: null, - text: (0, _i18N.t)('gameOver.restart'), - help: "" - } - ], - textAfterButtons: `
- ${getHistograms()} - ` - }).then(()=>restart({ - levelToAvoid: currentLevelInfo().name - })); -} -function getHistograms() { - let runStats = ""; - try { - // Stores only top 100 runs - let runsHistory = JSON.parse(localStorage.getItem("breakout_71_runs_history") || "[]"); - runsHistory.sort((a, b)=>a.score - b.score).reverse(); - runsHistory = runsHistory.slice(0, 100); - runsHistory.push({ - ...gameState.runStatistics, - perks: gameState.perks, - appVersion: (0, _loadGameData.appVersion) - }); - // Generate some histogram - if (!gameState.isCreativeModeRun) localStorage.setItem("breakout_71_runs_history", JSON.stringify(runsHistory, null, 2)); - const makeHistogram = (title, getter, unit)=>{ - let values = runsHistory.map((h)=>getter(h) || 0); - let min = Math.min(...values); - let max = Math.max(...values); - // No point - if (min === max) return ""; - if (max - min < 10) { - // This is mostly useful for levels - min = Math.max(0, max - 10); - max = Math.max(max, min + 10); - } - // One bin per unique value, max 10 - const binsCount = Math.min(values.length, 10); - if (binsCount < 3) return ""; - const bins = []; - const binsTotal = []; - for(let i = 0; i < binsCount; i++){ - bins.push(0); - binsTotal.push(0); - } - const binSize = (max - min) / bins.length; - const binIndexOf = (v)=>Math.min(bins.length - 1, Math.floor((v - min) / binSize)); - values.forEach((v)=>{ - if (isNaN(v)) return; - const index = binIndexOf(v); - bins[index]++; - binsTotal[index] += v; - }); - if (bins.filter((b)=>b).length < 3) return ""; - const maxBin = Math.max(...bins); - const lastValue = values[values.length - 1]; - const activeBin = binIndexOf(lastValue); - const bars = bins.map((v, vi)=>{ - const style = `height: ${v / maxBin * 80}px`; - return `${!v && " " || vi == activeBin && lastValue + unit || Math.round(binsTotal[vi] / v) + unit}`; - }).join(""); - return `

${title} : ${lastValue}${unit}

-
${bars}
- `; - }; - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.total_score'), (r)=>r.score, ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.catch_rate'), (r)=>Math.round(r.score / r.coins_spawned * 100), "%"); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.bricks_broken'), (r)=>r.bricks_broken, ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.bricks_per_minute'), (r)=>Math.round(r.bricks_broken / r.runTime * 60000), ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.hit_rate'), (r)=>Math.round((1 - r.misses / r.puck_bounces) * 100), "%"); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.duration_per_level'), (r)=>Math.round(r.runTime / 1000 / r.levelsPlayed), "s"); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.level_reached'), (r)=>r.levelsPlayed, ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.upgrades_applied'), (r)=>r.upgrades_picked, ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.balls_lost'), (r)=>r.balls_lost, ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.combo_avg'), (r)=>Math.round(r.coins_spawned / r.bricks_broken), ""); - runStats += makeHistogram((0, _i18N.t)('gameOver.stats.combo_max'), (r)=>r.max_combo, ""); - if (runStats) runStats = `

${(0, _i18N.t)('gameOver.stats.intro', { - count: runsHistory.length - 1 - })}

` + runStats; - } catch (e) { - console.warn(e); - } - return runStats; -} -function explodeBrick(index, ball, isExplosion) { - const color = gameState.bricks[index]; - if (!color) return; - if (color === "black") { - delete gameState.bricks[index]; - const x = brickCenterX(index), y = brickCenterY(index); - (0, _sounds.sounds).explode(ball.x); - const col = index % gameState.gridSize; - const row = Math.floor(index / gameState.gridSize); - const size = 1 + gameState.perks.bigger_explosions; - // Break bricks around - for(let dx = -size; dx <= size; dx++)for(let dy = -size; dy <= size; dy++){ - const i = getRowColIndex(row + dy, col + dx); - if (gameState.bricks[i] && i !== -1) { - // Study bricks resist explosions too - if (gameState.bricks[i] !== "black" && gameState.perks.sturdy_bricks > Math.random() * 5) continue; - explodeBrick(i, ball, true); - } - } - // Blow nearby coins - gameState.coins.forEach((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; - c.vy += dy / d2 * 10 * size / c.weight; - }); - gameState.lastExplosion = Date.now(); - gameState.flashes.push({ - type: "ball", - duration: 150, - time: gameState.levelTime, - size: gameState.brickWidth * 2, - color: "white", - x, - y - }); - spawnExplosion(7 * (1 + gameState.perks.bigger_explosions), x, y, "white", 150, gameState.coinSize); - ball.hitSinceBounce++; - gameState.runStatistics.bricks_broken++; - } else if (color) { - // Even if it bounces we don't want to count that as a miss - ball.hitSinceBounce++; - // Flashing is take care of by the tick loop - const x = brickCenterX(index), y = brickCenterY(index); - gameState.bricks[index] = ""; - // coins = coins.filter((c) => !c.destroyed); - 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 = gameState.MAX_COINS * ((0, _options.isOptionOn)("basic") ? 0.5 : 1); - const spawnableCoins = gameState.coins.length > gameState.MAX_COINS ? 1 : Math.floor(maxCoins - gameState.coins.length) / 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); - gameState.coins.push({ - points, - size: gameState.coinSize, - color: gameState.perks.metamorphosis ? color : "gold", - x: cx, - y: cy, - previousX: cx, - previousY: cy, - // Use previous speed because the ball has already bounced - vx: ball.previousVX * (0.5 + Math.random()), - vy: ball.previousVY * (0.5 + Math.random()), - sx: 0, - sy: 0, - a: Math.random() * Math.PI * 2, - sa: Math.random() - 0.5, - weight: 0.8 + Math.random() * 0.2 - }); - } - gameState.combo += Math.max(0, 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 - Math.round(Math.random() * gameState.perks.soft_reset)); - if (!isExplosion) { - // color change - if ((gameState.perks.picky_eater || gameState.perks.pierce_color) && color !== gameState.ballsColor && color) { - if (gameState.perks.picky_eater) (0, _combo.resetCombo)(gameState, ball.x, ball.y); - (0, _sounds.sounds).colorChange(ball.x, 0.8); - gameState.lastExplosion = gameState.levelTime; - gameState.ballsColor = color; - if (!(0, _options.isOptionOn)("basic")) gameState.balls.forEach((ball)=>{ - spawnExplosion(7, ball.previousX, ball.previousY, color, 150, 15); - }); - } else (0, _sounds.sounds).comboIncreaseMaybe(gameState.combo, ball.x, 1); - } - gameState.flashes.push({ - type: "ball", - duration: 40, - time: gameState.levelTime, - size: gameState.brickWidth, - color: color, - x, - y - }); - spawnExplosion(5 + Math.min(gameState.combo, 30), x, y, color, 150, gameState.coinSize / 2); - } - if (!gameState.bricks[index] && color !== "black") ball.hitItem?.push({ - index, - color - }); -} -function max_levels() { - return 7 + gameState.perks.extra_levels; -} -function render() { - if (gameState.running) gameState.needsRender = true; - if (!gameState.needsRender) return; - gameState.needsRender = false; - const level = currentLevelInfo(); - const { width, height } = gameCanvas; - if (!width || !height) return; - if (gameState.currentLevel || gameState.levelTime) menuLabel.innerText = (0, _i18N.t)('play.current_lvl', { - level: gameState.currentLevel + 1, - max: max_levels() - }); - else menuLabel.innerText = (0, _i18N.t)('play.menu_label'); - scoreDisplay.innerText = `$${gameState.score}`; - scoreDisplay.className = gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : ""; - // Clear - if (!(0, _options.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; - gameState.coins.forEach((coin)=>{ - if (!coin.destroyed) 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(index), y = brickCenterY(index); - drawFuzzyBall(ctx, color == "black" ? "#666" : color, gameState.brickWidth, x, y); - }); - ctx.globalAlpha = 1; - gameState.flashes.forEach((flash)=>{ - const { x, y, time, color, size, type, duration } = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - elapsed / duration * 2); - if (type === "ball") drawFuzzyBall(ctx, color, size, x, y); - if (type === "particle") 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"); - bgctx.fillStyle = level.color || "#000"; - bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight); - 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); - gameState.flashes.forEach((flash)=>{ - const { x, y, time, color, size, type, duration } = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - elapsed / duration * 2); - if (type === "particle") drawBall(ctx, color, size, x, y); - }); - } - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5; - const shaked = lastExplosionDelay < 200 && !(0, _options.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 && !(0, _options.isOptionOn)('basic')) { - if (shaked) gameCanvas.style.filter = 'brightness(' + (1 + 100 / (1 + lastExplosionDelay)) + ')'; - else gameCanvas.style.filter = ''; - } - // Coins - ctx.globalAlpha = 1; - gameState.coins.forEach((coin)=>{ - if (!coin.destroyed) { - ctx.globalCompositeOperation = coin.color === "gold" || level.color ? "source-over" : "screen"; - drawCoin(ctx, coin.color, coin.size, coin.x, coin.y, level.color || "black", coin.a); - } - }); - // Black shadow around balls - if (!(0, _options.isOptionOn)("basic")) { - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 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"; - gameState.flashes = gameState.flashes.filter((f)=>gameState.levelTime - f.time < f.duration && !f.destroyed); - gameState.flashes.forEach((flash)=>{ - const { x, y, time, color, size, type, duration } = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - elapsed / duration * 2)); - if (type === "text") { - ctx.globalCompositeOperation = "source-over"; - drawText(ctx, flash.text, color, size, x, y - elapsed / 10); - } else if (type === "particle") { - 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.offsetXRoundedDown, gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, gameState.gameZoneWidthRoundedUp, 1); - } - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - gameState.balls.forEach((ball)=>{ - // The white border around is to distinguish colored balls from coins/bg - drawBall(ctx, gameState.ballsColor, gameState.ballSize, ball.x, ball.y, gameState.puckColor); - if (isTelekinesisActive(ball)) { - ctx.strokeStyle = gameState.puckColor; - ctx.beginPath(); - ctx.bezierCurveTo(gameState.puckPosition, gameState.gameZoneHeight, gameState.puckPosition, ball.y, ball.x, ball.y); - ctx.stroke(); - } - }); - // The puck - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - if (gameState.perks.streak_shots && gameState.combo > (0, _combo.baseCombo)(gameState)) drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2); - drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight); - 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, comboText, "#FFF", gameState.puckHeight, gameState.puckPosition, gameState.gameZoneHeight - gameState.puckHeight / 2, false); - } - // Borders - const hasCombo = gameState.combo > (0, _combo.baseCombo)(gameState); - ctx.globalCompositeOperation = "source-over"; - 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; - ctx.fillRect(gameState.offsetX - 1, 0, 1, height); - ctx.fillStyle = hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; - ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); - } else { - ctx.fillStyle = "red"; - if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); - if (hasCombo && gameState.perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); - } - if (gameState.perks.top_is_lava && gameState.combo > (0, _combo.baseCombo)(gameState)) { - ctx.fillStyle = "red"; - ctx.fillRect(gameState.offsetXRoundedDown, 0, gameState.gameZoneWidthRoundedUp, 1); - } - const redBottom = gameState.perks.compound_interest && gameState.combo > (0, _combo.baseCombo)(gameState); - ctx.fillStyle = redBottom ? "red" : gameState.puckColor; - if ((0, _options.isOptionOn)("mobile-mode")) { - ctx.fillRect(gameState.offsetXRoundedDown, gameState.gameZoneHeight, gameState.gameZoneWidthRoundedUp, 1); - if (!gameState.running) drawText(ctx, (0, _i18N.t)('play.mobile_press_to_play'), gameState.puckColor, gameState.puckHeight, gameState.canvasWidth / 2, gameState.gameZoneHeight + (gameState.canvasHeight - gameState.gameZoneHeight) / 2); - } else if (redBottom) ctx.fillRect(gameState.offsetXRoundedDown, gameState.gameZoneHeight - 1, gameState.gameZoneWidthRoundedUp, 1); - if (shaked) ctx.resetTransform(); - recordOneFrame(); -} -let cachedBricksRender = document.createElement("canvas"); -let cachedBricksRenderKey = ""; -function renderAllBricks() { - ctx.globalAlpha = 1; - const redBorderOnBricksWithWrongColor = gameState.combo > (0, _combo.baseCombo)(gameState) && gameState.perks.picky_eater && !(0, _options.isOptionOn)('basic'); - const newKey = gameState.gameZoneWidth + "_" + gameState.bricks.join("_") + bombSVG.complete + "_" + redBorderOnBricksWithWrongColor + "_" + gameState.ballsColor + "_" + gameState.perks.pierce_color; - if (newKey !== cachedBricksRenderKey) { - cachedBricksRenderKey = newKey; - cachedBricksRender.width = gameState.gameZoneWidth; - cachedBricksRender.height = gameState.gameZoneWidth + 1; - const canctx = cachedBricksRender.getContext("2d"); - canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth); - canctx.resetTransform(); - canctx.translate(-gameState.offsetX, 0); - // Bricks - gameState.bricks.forEach((color, index)=>{ - const x = brickCenterX(index), y = brickCenterY(index); - if (!color) return; - const borderColor = gameState.ballsColor !== color && color !== "black" && redBorderOnBricksWithWrongColor && "red" || color; - drawBrick(canctx, color, borderColor, x, y); - if (color === "black") { - canctx.globalCompositeOperation = "source-over"; - drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); - } - }); - } - ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); -} -let cachedGraphics = {}; -function drawPuck(ctx, color, puckWidth, puckHeight, yOffset = 0) { - const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = puckWidth; - can.height = puckHeight * 2; - const canctx = can.getContext("2d"); - canctx.fillStyle = color; - canctx.beginPath(); - canctx.moveTo(0, puckHeight * 2); - 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(); - cachedGraphics[key] = can; - } - ctx.drawImage(cachedGraphics[key], Math.round(gameState.puckPosition - puckWidth / 2), gameState.gameZoneHeight - puckHeight * 2 + yOffset); -} -function drawBall(ctx, color, width, x, y, 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 canctx = can.getContext("2d"); - 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; - } - ctx.drawImage(cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2)); -} -const angles = 32; -function drawCoin(ctx, color, size, x, y, borderColor, rawAngle) { - 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; - const canctx = can.getContext("2d"); - // coin - canctx.beginPath(); - canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); - if (color === "gold") { - canctx.strokeStyle = borderColor; - canctx.stroke(); - 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)); -} -function drawFuzzyBall(ctx, color, width, x, y) { - 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"); - 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)); -} -function drawBrick(ctx, color, borderColor, x, y) { - 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 + "_" + borderColor + "_" + width + "_" + height; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = width; - can.height = height; - const bord = 2; - const cornerRadius = 2; - const canctx = can.getContext("2d"); - canctx.fillStyle = color; - canctx.strokeStyle = borderColor; - canctx.lineJoin = "round"; - canctx.lineWidth = bord; - roundRect(canctx, bord / 2, bord / 2, width - bord, height - bord, cornerRadius); - canctx.fill(); - 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 -} -function roundRect(ctx, x, y, width, height, radius) { - 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(); -} -function drawIMG(ctx, img, size, x, y) { - const key = "svg" + img + "_" + size + "_" + img.complete; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; - const canctx = can.getContext("2d"); - 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)); -} -function drawText(ctx, text, color, fontSize, x, y, left = false) { - 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"); - 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); - cachedGraphics[key] = can; - } - ctx.drawImage(cachedGraphics[key], left ? x : Math.round(x - cachedGraphics[key].width / 2), Math.round(y - cachedGraphics[key].height / 2)); + (0, _render.render)(gameState); + (0, _recording.recordOneFrame)(gameState); + requestAnimationFrame(tick); } window.addEventListener("visibilitychange", ()=>{ if (document.hidden) pause(true); }); -const scoreDisplay = document.getElementById("score"); -const menuLabel = document.getElementById("menuLabel"); -let alertsOpen = 0, closeModal = null; -function asyncAlert({ title, text, actions, allowClose = true, textAfterButtons = "", actionsAsGrid = false }) { - alertsOpen++; - return new Promise((resolve)=>{ - const popupWrap = document.createElement("div"); - document.body.appendChild(popupWrap); - popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); - function closeWithResult(value) { - resolve(value); - // Doing this async lets the menu scroll persist if it's shown a second time - setTimeout(()=>{ - document.body.removeChild(popupWrap); - }); - } - if (allowClose) { - const closeButton = document.createElement("button"); - closeButton.title = (0, _i18N.t)('play.close_modale_window_tooltip'); - closeButton.className = "close-modale"; - closeButton.addEventListener("click", (e)=>{ - e.preventDefault(); - closeWithResult(undefined); - }); - closeModal = ()=>{ - closeWithResult(undefined); - }; - popupWrap.appendChild(closeButton); - } - const popup = document.createElement("div"); - if (title) { - const p = document.createElement("h2"); - p.innerHTML = title; - popup.appendChild(p); - } - if (text) { - const p = document.createElement("div"); - p.innerHTML = text; - popup.appendChild(p); - } - const buttons = document.createElement("section"); - popup.appendChild(buttons); - actions?.filter((i)=>i).forEach(({ text, value, help, disabled, className = "", icon = "" })=>{ - const button = document.createElement("button"); - button.innerHTML = ` -${icon} -
- ${text} - ${help || ""} -
`; - if (disabled) button.setAttribute("disabled", "disabled"); - else button.addEventListener("click", (e)=>{ - e.preventDefault(); - closeWithResult(value); - }); - button.className = className; - buttons.appendChild(button); - }); - if (textAfterButtons) { - const p = document.createElement("div"); - p.className = "textAfterButtons"; - p.innerHTML = textAfterButtons; - popup.appendChild(p); - } - popupWrap.appendChild(popup); - popup.querySelector("button:not([disabled])")?.focus(); - }).then((v)=>{ - alertsOpen--; - closeModal = null; - return v; - }, ()=>{ - closeModal = null; - alertsOpen--; - }); -} -scoreDisplay.addEventListener("click", (e)=>{ +(0, _render.scoreDisplay).addEventListener("click", (e)=>{ e.preventDefault(); openScorePanel(); }); @@ -2143,16 +866,16 @@ document.addEventListener("visibilitychange", ()=>{ }); async function openScorePanel() { pause(true); - const cb = await asyncAlert({ + const cb = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('score_panel.title', { score: gameState.score, level: gameState.currentLevel + 1, - max: max_levels() + max: (0, _gameUtils.max_levels)(gameState) }), text: ` ${gameState.isCreativeModeRun ? "

${t('score_panel.test_run}

" : ""}

${(0, _i18N.t)('score_panel.upgrades_picked')}

-

${pickedUpgradesHTMl()}

+

${(0, _gameUtils.pickedUpgradesHTMl)(gameState)}

`, allowClose: true, actions: [ @@ -2166,7 +889,7 @@ async function openScorePanel() { help: (0, _i18N.t)('score_panel.restart_help'), value: ()=>{ restart({ - levelToAvoid: currentLevelInfo().name + levelToAvoid: (0, _gameUtils.currentLevelInfo)(gameState).name }); } } @@ -2201,6 +924,7 @@ async function openSettingsPanel() { help: (0, _options.options)[key].help, value: ()=>{ (0, _options.toggleOption)(key); + if (key === "mobile-mode") fitSize(); openSettingsPanel(); } }); @@ -2225,13 +949,13 @@ async function openSettingsPanel() { } actions.push({ text: (0, _i18N.t)('sandbox.title'), - help: getTotalScore() < creativeModeThreshold ? (0, _i18N.t)('sandbox.unlocks_at', { + help: (0, _settings.getTotalScore)() < creativeModeThreshold ? (0, _i18N.t)('sandbox.unlocks_at', { score: creativeModeThreshold }) : (0, _i18N.t)('sandbox.help'), - disabled: getTotalScore() < creativeModeThreshold, + disabled: (0, _settings.getTotalScore)() < creativeModeThreshold, async value () { let creativeModePerks = (0, _settings.getSettingValue)('creativeModePerks', {}), choice; - while(choice = await asyncAlert({ + while(choice = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('sandbox.title'), text: (0, _i18N.t)('sandbox.instructions'), actionsAsGrid: true, @@ -2263,7 +987,7 @@ async function openSettingsPanel() { text: (0, _i18N.t)('main_menu.reset'), help: (0, _i18N.t)('main_menu.reset_help'), async value () { - if (await asyncAlert({ + if (await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('main_menu.reset'), text: (0, _i18N.t)('main_menu.reset_instruction'), actions: [ @@ -2287,7 +1011,7 @@ async function openSettingsPanel() { text: (0, _i18N.t)('main_menu.language'), help: (0, _i18N.t)('main_menu.language_help'), async value () { - const pick = await asyncAlert({ + const pick = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('main_menu.language'), text: (0, _i18N.t)('main_menu.language_help'), actions: [ @@ -2308,7 +1032,7 @@ async function openSettingsPanel() { } } }); - const cb = await asyncAlert({ + const cb = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('main_menu.title'), text: ``, allowClose: true, @@ -2320,7 +1044,7 @@ async function openSettingsPanel() { if (cb) cb(); } async function openUnlocksList() { - const ts = getTotalScore(); + const ts = (0, _settings.getTotalScore)(); const actions = [ ...(0, _loadGameData.upgrades).sort((a, b)=>a.threshold - b.threshold).map(({ name, id, threshold, icon, fullHelp })=>({ text: name, @@ -2354,7 +1078,7 @@ async function openUnlocksList() { }) ]; const percentUnlock = Math.round(actions.filter((a)=>!a.disabled).length / actions.length * 100); - const tryOn = await asyncAlert({ + const tryOn = await (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('unlocks.title', { percentUnlock }), @@ -2376,7 +1100,7 @@ Click an item above to start a run with it. } async function confirmRestart() { if (!gameState.currentLevel) return true; - return asyncAlert({ + return (0, _asyncAlert.asyncAlert)({ title: (0, _i18N.t)('confirmRestart.title'), text: (0, _i18N.t)('confirmRestart.text'), actions: [ @@ -2391,196 +1115,6 @@ async function confirmRestart() { ] }); } -function distance2(a, b) { - return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); -} -function distanceBetween(a, b) { - return Math.sqrt(distance2(a, b)); -} -function rainbowColor() { - return `hsl(${Math.round(gameState.levelTime / 4) * 2 % 360},100%,70%)`; -} -function repulse(a, b, power, impactsBToo) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameState.gameZoneWidth / 2; - 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; - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand - }); - if (impactsBToo && typeof b.vx !== "undefined" && typeof b.vy !== "undefined") gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + b.vy + (Math.random() - 0.5) * rand - }); -} -function attract(a, b, power) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = gameState.gameZoneWidth * 0.5; - 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 speed = 10; - const rand = 2; - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + a.vy + (Math.random() - 0.5) * rand - }); - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand - }); -} -let mediaRecorder, captureStream, captureTrack, recordCanvas, recordCanvasCtx; -function recordOneFrame() { - if (!(0, _options.isOptionOn)("record")) return; - if (!gameState.running) return; - if (!captureStream) return; - drawMainCanvasOnSmallCanvas(); - if (captureTrack?.requestFrame) captureTrack?.requestFrame(); - else if (captureStream?.requestFrame) captureStream.requestFrame(); -} -function drawMainCanvasOnSmallCanvas() { - if (!recordCanvasCtx) return; - recordCanvasCtx.drawImage(gameCanvas, gameState.offsetXRoundedDown, 0, gameState.gameZoneWidthRoundedUp, gameState.gameZoneHeight, 0, 0, recordCanvas.width, recordCanvas.height); - // Here we don't use drawText as we don't want to cache a picture for each distinct value of score - recordCanvasCtx.fillStyle = "#FFF"; - recordCanvasCtx.textBaseline = "top"; - recordCanvasCtx.font = "12px monospace"; - recordCanvasCtx.textAlign = "right"; - recordCanvasCtx.fillText(gameState.score.toString(), recordCanvas.width - 12, 12); - recordCanvasCtx.textAlign = "left"; - recordCanvasCtx.fillText("Level " + (gameState.currentLevel + 1) + "/" + max_levels(), 12, 12); -} -function startRecordingGame() { - if (!(0, _options.isOptionOn)("record")) return; - if (mediaRecorder) return; - if (!recordCanvas) { - // Smaller canvas with fewer details - recordCanvas = document.createElement("canvas"); - recordCanvasCtx = recordCanvas.getContext("2d", { - antialias: false, - alpha: false - }); - captureStream = recordCanvas.captureStream(0); - captureTrack = captureStream.getVideoTracks()[0]; - const track = (0, _sounds.getAudioRecordingTrack)(); - if (track) captureStream.addTrack(track.stream.getAudioTracks()[0]); - } - recordCanvas.width = gameState.gameZoneWidthRoundedUp; - recordCanvas.height = gameState.gameZoneHeight; - // drawMainCanvasOnSmallCanvas() - const recordedChunks = []; - const instance = new MediaRecorder(captureStream, { - videoBitsPerSecond: 3500000 - }); - mediaRecorder = instance; - instance.start(); - mediaRecorder.pause(); - instance.ondataavailable = function(event) { - recordedChunks.push(event.data); - }; - instance.onstop = async function() { - let targetDiv; - let blob = new Blob(recordedChunks, { - type: "video/webm" - }); - if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short - while(!(targetDiv = document.getElementById("level-recording-container")))await new Promise((r)=>setTimeout(r, 200)); - const video = document.createElement("video"); - video.autoplay = true; - video.controls = false; - video.disablePictureInPicture = true; - video.disableRemotePlayback = true; - video.width = recordCanvas.width; - video.height = recordCanvas.height; - video.loop = true; - video.muted = true; - video.playsInline = true; - video.src = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.download = captureFileName("webm"); - a.target = "_blank"; - a.href = video.src; - a.textContent = (0, _i18N.t)('main_menu.record_download', { - size: (blob.size / 1000000).toFixed(2) - }); - targetDiv.appendChild(video); - targetDiv.appendChild(a); - }; -} -function pauseRecording() { - if (!(0, _options.isOptionOn)("record")) return; - if (mediaRecorder?.state === "recording") mediaRecorder?.pause(); -} -function resumeRecording() { - if (!(0, _options.isOptionOn)("record")) return; - if (mediaRecorder?.state === "paused") mediaRecorder.resume(); -} -function stopRecording() { - if (!(0, _options.isOptionOn)("record")) return; - if (!mediaRecorder) return; - mediaRecorder?.stop(); - mediaRecorder = null; -} -function captureFileName(ext = "webm") { - return "breakout-71-capture-" + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + "." + ext; -} -function findLast(arr, predicate) { - let i = arr.length; - while(--i)if (predicate(arr[i], i, arr)) return arr[i]; -} function toggleFullScreen() { try { if (document.fullscreenElement !== null) { @@ -2607,7 +1141,7 @@ function setKeyPressed(key, on) { document.addEventListener("keydown", (e)=>{ 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 (e.key === " " && !(0, _asyncAlert.alertsOpen)) { if (gameState.running) pause(true); else play(); } else return; @@ -2618,104 +1152,22 @@ document.addEventListener("keyup", async (e)=>{ if (e.key in pressed) setKeyPressed(e.key, 0); else if (e.key === "ArrowDown" && focused?.nextElementSibling?.tagName === "BUTTON") focused?.nextElementSibling?.focus(); else if (e.key === "ArrowUp" && focused?.previousElementSibling?.tagName === "BUTTON") focused?.previousElementSibling?.focus(); - else if (e.key === "Escape" && closeModal) closeModal(); + else if (e.key === "Escape" && (0, _asyncAlert.closeModal)) (0, _asyncAlert.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) { + else if (e.key.toLowerCase() === "m" && !(0, _asyncAlert.alertsOpen)) openSettingsPanel().then(); + else if (e.key.toLowerCase() === "s" && !(0, _asyncAlert.alertsOpen)) openScorePanel().then(); + else if (e.key.toLowerCase() === "r" && !(0, _asyncAlert.alertsOpen)) { if (await confirmRestart()) restart({ - levelToAvoid: currentLevelInfo().name + levelToAvoid: (0, _gameUtils.currentLevelInfo)(gameState).name }); } else return; e.preventDefault(); }); -function newGameState(params) { - const totalScoreAtRunStart = getTotalScore(); - const firstLevel = params?.level ? (0, _loadGameData.allLevels).filter((l)=>l.name === params?.level) : []; - const restInRandomOrder = (0, _loadGameData.allLevels).filter((l)=>totalScoreAtRunStart >= l.threshold).filter((l)=>l.name !== params?.level).filter((l)=>l.name !== params?.levelToAvoid).sort(()=>Math.random() - 0.5); - const runLevels = firstLevel.concat(restInRandomOrder.slice(0, 10).sort((a, b)=>a.sortKey - b.sortKey)); - const perks = { - ...(0, _gameUtils.makeEmptyPerksMap)((0, _loadGameData.upgrades)), - ...params?.perks || {} - }; - const gameState = { - runLevels, - currentLevel: 0, - perks, - puckWidth: 200, - baseSpeed: 12, - combo: 1, - gridSize: 12, - running: false, - puckPosition: 400, - pauseTimeout: null, - canvasWidth: 0, - canvasHeight: 0, - offsetX: 0, - offsetXRoundedDown: 0, - gameZoneWidth: 0, - gameZoneWidthRoundedUp: 0, - gameZoneHeight: 0, - brickWidth: 0, - needsRender: true, - score: 0, - lastScoreIncrease: -1000, - lastExplosion: -1000, - highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"), - balls: [], - ballsColor: "white", - bricks: [], - flashes: [], - coins: [], - levelStartScore: 0, - levelMisses: 0, - levelSpawnedCoins: 0, - lastPlayedCoinGrab: 0, - MAX_COINS: 400, - MAX_PARTICLES: 600, - puckColor: "#FFF", - ballSize: 20, - coinSize: 14, - puckHeight: 20, - totalScoreAtRunStart, - isCreativeModeRun: (0, _gameUtils.sumOfKeys)(perks) > 1, - pauseUsesDuringRun: 0, - keyboardPuckSpeed: 0, - lastTick: performance.now(), - lastTickDown: 0, - runStatistics: { - started: Date.now(), - levelsPlayed: 0, - runTime: 0, - coins_spawned: 0, - score: 0, - bricks_broken: 0, - misses: 0, - balls_lost: 0, - puck_bounces: 0, - upgrades_picked: 1, - max_combo: 1, - max_level: 0 - }, - lastOffered: {}, - levelTime: 0, - autoCleanUses: 0 - }; - (0, _resetBalls.resetBalls)(gameState); - if (!(0, _gameUtils.sumOfKeys)(gameState.perks)) { - const giftable = getPossibleUpgrades(gameState).filter((u)=>u.giftable); - const randomGift = (0, _options.isOptionOn)("easy") && "slow_down" || giftable[Math.floor(Math.random() * giftable.length)].id; - perks[randomGift] = 1; - dontOfferTooSoon(gameState, randomGift); - } - for (let perk of (0, _loadGameData.upgrades))if (gameState.perks[perk.id]) dontOfferTooSoon(gameState, perk.id); - return gameState; -} -const gameState = newGameState({}); +const gameState = (0, _newGameState.newGameState)({}); function restart(params) { - Object.assign(gameState, newGameState(params)); - pauseRecording(); - setLevel(0); + Object.assign(gameState, (0, _newGameState.newGameState)(params)); + (0, _recording.pauseRecording)(); + (0, _gameStateMutators.setLevel)(gameState, 0); } restart({}); fitSize(); @@ -2731,7 +1183,7 @@ window.stressTest = ()=>restart({ } }); -},{"./loadGameData":"l1B4x","./options":"d5NoS","./sounds":"dQKPV","./resetBalls":"gVgfx","./game_utils":"cEeac","./combo":"9S1mS","./sw_loader":"kRstf","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./i18n/i18n":"eNPRm","./settings":"5blfu"}],"l1B4x":[function(require,module,exports,__globalThis) { +},{"./loadGameData":"l1B4x","./sounds":"dQKPV","./game_utils":"cEeac","./sw_loader":"kRstf","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./i18n/i18n":"eNPRm","./settings":"5blfu","./gameStateMutators":"9ZeQl","./render":"9AS2t","./recording":"godmD","./newGameState":"aQN6X","./asyncAlert":"rSqLY","./options":"d5NoS"}],"l1B4x":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); parcelHelpers.defineInteropFlag(exports); parcelHelpers.export(exports, "appVersion", ()=>appVersion); @@ -3058,9 +1510,11 @@ const rawUpgrades = [ threshold: 18000, giftable: false, id: "soft_reset", - max: 2, + max: 9, name: (0, _i18N.t)('upgrades.soft_reset.name'), - help: (lvl)=>(0, _i18N.t)('upgrades.soft_reset.help'), + help: (lvl)=>(0, _i18N.t)('upgrades.soft_reset.help', { + percent: 10 * lvl + }), fullHelp: (0, _i18N.t)('upgrades.soft_reset.fullHelp') }, { @@ -3179,10 +1633,10 @@ function getFirstBrowserLanguage() { } },{"./fr.json":"b97sx","./en.json":"uYc9N","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","../settings":"5blfu"}],"b97sx":[function(require,module,exports,__globalThis) { -module.exports = JSON.parse('{"confirmRestart.no":"Annuler ,continuer ma partie en cours","confirmRestart.text":"Vous \xeates sur le point de commencer une nouvelle partie, est-ce vraiment ce que vous vouliez ?","confirmRestart.title":"D\xe9marrer une nouvelle partie ?","confirmRestart.yes":"Commencer une nouvelle partie","gameOver.cumulative_total":"Votre score total cumul\xe9 est pass\xe9 de {{startTs}} \xe0 {{endTs}}.","gameOver.lost.summary":"Vous avez fait tomber la balle apr\xe8s avoir attrap\xe9 {{score}} pi\xe8ces.","gameOver.lost.title":"Balle perdue","gameOver.next_unlock":"Marquez {{points}} points suppl\xe9mentaires pour d\xe9bloquer la prochaine am\xe9lioration ou le prochain niveau.","gameOver.restart":"Nouvelle partie","gameOver.stats.balls_lost":"Balles perdues","gameOver.stats.bricks_broken":"Briques cass\xe9es","gameOver.stats.bricks_per_minute":"Briques cass\xe9es par minute","gameOver.stats.catch_rate":"Taux de capture des pi\xe8ces","gameOver.stats.combo_avg":"Combo moyen","gameOver.stats.combo_max":"Combo maximum","gameOver.stats.duration_per_level":"Dur\xe9e par niveau","gameOver.stats.hit_rate":"Pr\xe9cision","gameOver.stats.intro":"Vous trouverez ci-dessous les statistiques de cette partie compar\xe9es \xe0 vos {{count}} meilleures parties.","gameOver.stats.level_reached":"Niveau atteint","gameOver.stats.total_score":"Score total","gameOver.stats.upgrades_applied":"Mises \xe0 jour appliqu\xe9es","gameOver.test_run":"Cette partie de test et son score ne sont pas enregistr\xe9s.","gameOver.unlocked_count":"Vous avez d\xe9bloqu\xe9 {{count}} objet(s) :","gameOver.win.summary":"Vous avez nettoy\xe9 tous les niveaux pour cette partie, en attrapant {{score}} pi\xe8ces au total.","gameOver.win.title":"Partie termin\xe9e","level_up.after_buttons":"Vous venez de terminer le niveau {{level}}/{{max}} et vous avez choisi ces am\xe9liorations jusqu\'\xe0 pr\xe9sent :","level_up.before_buttons":"Vous avez attrap\xe9 {{score}} pi\xe8ces {{catchGain}} sur {{levelSpawnedCoins}} en {{time}} secondes ${timeGain}.\\n\\nVous avez rat\xe9 les briques {{levelMisses}} fois {{missesGain}}.\\n\\n{{compliment}}","level_up.compliment_advice":"Essayez d\'attraper toutes les pi\xe8ces, de ne jamais rater les briques ou de terminer le niveau en moins de 30 secondes pour obtenir des choix suppl\xe9mentaires et des am\xe9liorations.","level_up.compliment_good":"Bravo !","level_up.compliment_perfect":"Impressionnant, continuez comme \xe7a !","level_up.pick_upgrade_title":"Choisir une am\xe9lioration","level_up.plus_one_choice":"(+1 choix)","level_up.plus_one_upgrade":"(+1 am\xe9lioration et +1 choix)","level_up.unlocked_level":" (Niveau)","level_up.unlocked_perk":" (Am\xe9lioration)","level_up.upgrade_perk_to_level":" niveau {{level}}","main_menu.basic":"Graphismes simplifi\xe9s","main_menu.basic_help":"Moins de particules et effets, meilleures performances.","main_menu.footer_html":"

Programm\xe9 en France par Renan LE CARO. Politique de confidentialit\xe9 F-Droid Google Play itch.io Gitlab Version web HackerNews v.{{appVersion}}

","main_menu.fullscreen":"Plein \xe9cran","main_menu.fullscreen_exit":"Quitter le plein \xe9cran","main_menu.fullscreen_exit_help":"Peut ne pas fonctionner sur certaines machines","main_menu.fullscreen_help":"Peut ne pas fonctionner sur certaines machines","main_menu.kid":"Mode enfants","main_menu.kid_help":"Balle plus lente","main_menu.language":"Langue","main_menu.language_help":"Changer la langue d\'affichage","main_menu.mobile":"Mode mobile","main_menu.mobile_help":"Laisse un espace pour le pouce sous le palet.","main_menu.pointer_lock":"Verrouillage du pointeur de la souris","main_menu.pointer_lock_help":"Verrouille et cache le curseur de la souris.","main_menu.record":"Enregistrer des vid\xe9os de jeu","main_menu.record_download":"T\xe9l\xe9charger la vid\xe9o ({{size}} MB)","main_menu.record_help":"Obtenez une vid\xe9o de chaque niveau.","main_menu.reset":"R\xe9initialiser le jeu","main_menu.reset_cancel":"Non","main_menu.reset_confirm":"Oui","main_menu.reset_help":"Effacer le meilleur score et les statistiques","main_menu.reset_instruction":"Vous perdrez tous les progr\xe8s que vous avez faits dans le jeu, \xeates-vous s\xfbr ?","main_menu.resume":"Retourner \xe0 la partie","main_menu.resume_help":"Continuer la partie en cours","main_menu.sounds":"Sons du jeu","main_menu.sounds_help":"Ralentis certains t\xe9l\xe9phones.","main_menu.title":"Breakout 71","main_menu.unlocks":"Am\xe9liorations et niveaux","main_menu.unlocks_help":"Essayez les \xe9l\xe9ments d\xe9bloqu\xe9s","play.close_modale_window_tooltip":"Fermer","play.current_lvl":"Niveau {{level}}/{{max}}","play.menu_label":"Menu","play.missed_ball":"rat\xe9","play.mobile_press_to_play":"Appuyez et maintenez ici pour jouer","sandbox.help":"Tester n\'importe quelle combinaison d\'am\xe9liorations","sandbox.instructions":"S\xe9lectionnez les am\xe9lioration ci-dessous et appuyez sur \\"D\xe9marrer la partie de test\\" pour les tester. Les scores et les statistiques ne seront pas enregistr\xe9s.","sandbox.start":"D\xe9marrer la partie de test","sandbox.title":"Mode bac \xe0 sable","sandbox.unlocks_at":"D\xe9verrouill\xe9 \xe0 partir d\'un score total de ${{score}}","score_panel.restart":"Red\xe9marrer","score_panel.restart_help":"Commencer une nouvelle partie","score_panel.resume":"Continuer la partie","score_panel.resume_help":"Fermer cette fen\xeatre pour retourner au jeu","score_panel.test_run":"Il s\'agit d\'une partie d\'essai, le score n\'est pas enregistr\xe9.","score_panel.title":"{{score}} points au niveau {{level}}/{{max}} ","score_panel.upgrades_picked":"Am\xe9liorations choisies jusqu\'\xe0 pr\xe9sent :","unlocks.greyed_out_help":"Les \xe9l\xe9ments gris\xe9es peuvent \xeatre d\xe9bloqu\xe9es en augmentant votre score total. Le score total augmente \xe0 chaque fois que vous marquez des points dans le jeu.","unlocks.intro":"Votre score total est de {{ts}}. Vous trouverez ci-dessous toutes les am\xe9liorations et tous les niveaux que le jeu peut offrir.","unlocks.level_description":"Un niveau {{size}}x{{size}} avec {{bricks}} briques","unlocks.title":"Vous avez d\xe9bloqu\xe9 {{percentUnlock}}% du jeu.","unlocks.unlocks_at":"D\xe9verrouill\xe9 au score total {{threshold}}.","upgrades.ball_attract_ball.fullHelp":"Les balles qui sont \xe9loign\xe9es de plus d\'une demi-largeur d\'\xe9cran commencent \xe0 s\'attirer. La force d\'attraction est plus forte lorsque les balles sont plus \xe9loign\xe9es l\'une de l\'autre. Des particules arc-en-ciel voleront pour symboliser la force d\'attraction. Cet avantage n\'est offert que si vous avez d\xe9j\xe0 plus d\'une balle en jeu.","upgrades.ball_attract_ball.help":"Les balles attirent les balles","upgrades.ball_attract_ball.help_plural":"Force d\'attraction plus forte","upgrades.ball_attract_ball.name":"Gravit\xe9","upgrades.ball_repulse_ball.fullHelp":"Les balles qui se trouvent \xe0 moins d\'une demi-largeur d\'\xe9cran commencent \xe0 se repousser les unes les autres. La force de r\xe9pulsion est plus forte si elles sont proches l\'une de l\'autre. Des particules seront affich\xe9es pour symboliser l\'application de cette force. Cet avantage n\'est offert que si vous avez d\xe9j\xe0 plus d\'une balle.","upgrades.ball_repulse_ball.help":"Les balles repoussent les balles","upgrades.ball_repulse_ball.help_plural":"Force de r\xe9pulsion plus forte","upgrades.ball_repulse_ball.name":"Vol en formation","upgrades.base_combo.fullHelp":"Votre combo (nombre de pi\xe8ces par brique) commence normalement \xe0 1 au d\xe9but du niveau et revient \xe0 1 lorsque vous rebondissez sans rien toucher. Avec cette caract\xe9ristique, le combo commence 3 points plus haut, ce qui fait que vous obtiendrez toujours au moins 4 pi\xe8ces par brique. Lorsque votre combo est r\xe9initialis\xe9, il revient \xe0 4 et non \xe0 1. Votre balle scintillera un peu pour indiquer que son combo est sup\xe9rieur \xe0 1.","upgrades.base_combo.help":"Chaque brique produit au moins {{coins}} pi\xe8ces.","upgrades.base_combo.name":"Combo +3","upgrades.bigger_explosions.fullHelp":"L\'explosion par d\xe9faut efface un carr\xe9 de 3x3 briques, avec cette am\xe9lioration un carr\xe9 de 5x5. Le vent soufflant les pi\xe8ces est \xe9galement beaucoup plus fort. L\'\xe9cran clignotera un peu apr\xe8s chaque explosion (sauf en mode graphismes basiques).","upgrades.bigger_explosions.help":"Explosions plus violentes","upgrades.bigger_explosions.name":"Kaboom","upgrades.bigger_puck.fullHelp":"Un grand palet permet de ne jamais rater la balle et d\'attraper plus de pi\xe8ces, ainsi que d\'orienter pr\xe9cis\xe9ment les rebonds (l\'angle de la balle ne d\xe9pend que de l\'endroit o\xf9 elle touche le palet). Cependant, un grand palet est plus difficile \xe0 utiliser sur les c\xf4t\xe9s du niveau.","upgrades.bigger_puck.help":"Attrapez facilement plus de pi\xe8ces.","upgrades.bigger_puck.name":"Palet plus grand","upgrades.coin_magnet.fullHelp":"Dirige les pi\xe8ces vers le palet. L\'effet est plus fort si la pi\xe8ce est d\xe9j\xe0 proche du palet. Attraper 90 % ou 100 % des pi\xe8ces apporte des bonus sp\xe9ciaux dans le jeu. Une autre fa\xe7on d\'attraper plus de pi\xe8ces est de frapper les briques par le bas. La vitesse et la direction de la balle ont un impact sur la vitesse des pi\xe8ces produites.","upgrades.coin_magnet.help":"Le palet attire les pi\xe8ces","upgrades.coin_magnet.help_plural":"Effet plus marqu\xe9 sur les pi\xe8ces","upgrades.coin_magnet.name":"Aimant pour pi\xe8ces","upgrades.compound_interest.fullHelp":"Votre combo augmentera d\'une unit\xe9 \xe0 chaque fois que vous casserez une brique, g\xe9n\xe9rant de plus en plus de pi\xe8ces \xe0 chaque fois que vous casserez une brique. Veillez cependant \xe0 attraper chacune de ces pi\xe8ces avec votre palet, car toute pi\xe8ce perdue remettra votre combo \xe0 z\xe9ro. \\n \\nSi votre combinaison est sup\xe9rieure au minimum, une ligne rouge s\'affichera au bas de la zone de jeu pour vous le rappeler que les pi\xe8ces ne doivent pas aller \xe0 cet endroit.\\n\\nCet avantage se combine avec d\'autres avantages de combo, le combo augmentera plus rapidement mais se r\xe9initialisera plus souvent.","upgrades.compound_interest.help":"Attrapez toutes les pi\xe8ces pour en avoir plus","upgrades.compound_interest.name":"Int\xe9r\xeats","upgrades.extra_levels.fullHelp":"La partie dure normalement 7 niveaux, apr\xe8s quoi le jeu est termin\xe9 et le score que vous avez atteint est votre score de partie.\\n\\nChoisir cette am\xe9lioration vous permet de prolonger la partie d\'un niveau. Les derniers niveaux sont souvent ceux o\xf9 vous faites le plus de points, la diff\xe9rence peut donc \xeatre spectaculaire.","upgrades.extra_levels.help":"Jouer {{count}} niveaux au lieu de 7","upgrades.extra_levels.name":"+1 niveau","upgrades.extra_life.fullHelp":"Normalement, vous n\'avez qu\'une seule balle par manche, et la manche est termin\xe9e d\xe8s que vous la laissez tomber.\\nCette comp\xe9tence ajoute une barre blanche en bas de l\'\xe9cran qui sauvera une balle une fois, et se brisera au cours du processus.\\nVous pouvez prendre plusieurs vies d\'avances, elle seront utilis\xe9es \xe0 chaque fois qu\'une balle est sur le point d\'\xeatre perdue. ","upgrades.extra_life.help":"La balle rebondit une fois avant d\'\xeatre perdue.","upgrades.extra_life.help_plural":"La balle rebondit {{lvl}} fois avant d\'\xeatre perdue.","upgrades.extra_life.name":"+1 vie","upgrades.hot_start.fullHelp":"Au d\xe9but de chaque niveau, votre combo commencera \xe0 +15 points, mais \xe0 chaque seconde, il sera diminu\xe9 d\'un point. Cela signifie que les 15 premi\xe8res secondes d\'un niveau produiront beaucoup plus de pi\xe8ces que les suivantes.\\nVous devez vous assurer de terminer le niveau rapidement. L\'effet se cumule avec d\'autres avantages li\xe9s au combo, ce qui vous permet d\'augmenter le combo apr\xe8s les 15 secondes, mais il continuera \xe0 diminuer chaque seconde. Chaque fois que vous reprenez la comp\xe9tence, l\'effet est encore plus prononc\xe9.","upgrades.hot_start.help":"Combo \xe0 {{start}}, -{{lvl}} combo par seconde","upgrades.hot_start.name":"D\xe9marrage \xe0 chaud","upgrades.instant_upgrade.fullHelp":"Choisissez imm\xe9diatement deux am\xe9liorations, afin d\'en obtenir une gratuite et une autre pour rembourser celle utilis\xe9e pour obtenir cet avantage. Chaque fois que vous choisirez des am\xe9liorations dans le menu suivant, vous aurez moins de choix.","upgrades.instant_upgrade.help":"-1 choix jusqu\'\xe0 la fin de la course.","upgrades.instant_upgrade.name":"+2 am\xe9liorations maintenant","upgrades.left_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9, ce qui vous permet d\'obtenir une pi\xe8ce de plus \xe0 chaque fois que vous cassez une brique.\\n\\nCependant, votre combinaison se r\xe9initialise d\xe8s que votre balle touche le c\xf4t\xe9 gauche.\\n\\nD\xe8s que votre combo augmente, le c\xf4t\xe9 gauche devient rouge pour vous rappeler que vous devez \xe9viter de le frapper.\\n\\nL\'effet se cumule avec d\'autres avantages de combo, le combo augmente plus rapidement avec plus d\'am\xe9liorations, mais il se r\xe9initialise \xe9galement si l\'une ou l\'autre des conditions de r\xe9initialisation est remplie. ","upgrades.left_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le c\xf4t\xe9 gauche.","upgrades.left_is_lava.name":"\xc9viter le c\xf4t\xe9 gauche","upgrades.metamorphosis.fullHelp":"Avec cette am\xe9lioration, les pi\xe8ces seront de la couleur de la brique d\'o\xf9 elles proviennent et coloreront la premi\xe8re brique qu\'elles toucheront. \\n\\nLes pi\xe8ces apparaissent \xe0 la vitesse de la balle qui les a cass\xe9es, ce qui signifie que vous pouvez viser un peu dans la direction des briques que vous voulez \\"peindre\\".","upgrades.metamorphosis.help":"Les pi\xe8ces de monnaie tachent les briques qu\'elles touchent","upgrades.metamorphosis.name":"M\xe9tamorphose","upgrades.multiball.fullHelp":"D\xe8s que vous laissez tomber la balle dans Breakout 71, vous perdez. \\n\\nAvec cet avantage, vous obtenez deux balles, et vous pouvez donc vous permettre d\'en perdre une.\\n\\nLes balles perdues reviennent au niveau suivant. \\n\\nLe fait d\'avoir plus d\'une balle permet d\'obtenir d\'autres avantages et, bien s\xfbr, de franchir le niveau plus rapidement.","upgrades.multiball.help":"Chaque niveau commence avec {{count}} balles.","upgrades.multiball.name":"+1 balle","upgrades.one_more_choice.fullHelp":"Chaque menu d\'am\xe9lioration comportera une option suppl\xe9mentaire. Cela n\'augmente pas le nombre d\'am\xe9liorations que vous pouvez choisir, mais vous aide \xe0 cr\xe9er le profile id\xe9al. ","upgrades.one_more_choice.help":"Les niveaux suivants offriront une option suppl\xe9mentaire dans la liste d\'am\xe9liorations.","upgrades.one_more_choice.name":"+1 choix jusqu\'\xe0 la fin de la course","upgrades.picky_eater.fullHelp":"Chaque fois que vous cassez une brique de la m\xeame couleur que votre balle, votre combo augmente d\'une unit\xe9.\\n\\nS\'il s\'agit d\'une couleur diff\xe9rente, la balle adopte cette nouvelle couleur, mais la combinaison est r\xe9initialis\xe9e.\\n\\nLes briques de la mauvaise couleur sont entour\xe9es en rouge.\\n\\nSi vous avez plus d\'une balle, elles changent toutes de couleur lorsque l\'une d\'entre elles touche une brique.","upgrades.picky_eater.help":"Plus de pi\xe8ces si vous cassez les briques couleur par couleur.","upgrades.picky_eater.name":"Mangeur par couleur","upgrades.pierce.fullHelp":"Normalement , la balle rebondit d\xe8s qu\'elle touche une brique. Avec cette caract\xe9ristique, elle continuera sa trajectoire jusqu\'\xe0 3 briques cass\xe9es.\\n\\nApr\xe8s cela, elle rebondira sur la quatri\xe8me brique et vous devez toucher le palet pour remettre le compteur \xe0 z\xe9ro.","upgrades.pierce.help":"La balle perce {{count}} briques apr\xe8s chaque rebond sur le palet","upgrades.pierce.name":"Balle per\xe7ante","upgrades.pierce_color.fullHelp":"Chaque fois qu\'une balle touche une brique de la m\xeame couleur, elle la traverse sans encombre.\\nLorsqu\'elle atteint une brique de couleur diff\xe9rente, elle la casse, prend sa couleur et rebondit.","upgrades.pierce_color.help":"Les balles transpercent les briques de leur couleur","upgrades.pierce_color.name":"Perceur de couleur","upgrades.puck_repulse_ball.fullHelp":"Lorsqu\'une balle s\'approche du palet, elle commence \xe0 ralentir, voire \xe0 rebondir sans toucher le palet. Beaucoup de choses sont li\xe9es \xe0 un passage par le palet dans le jeu, donc \xe7a pourrait ouvrir des possibilit\xe9s. ","upgrades.puck_repulse_ball.help":"Le palet repousse les balles","upgrades.puck_repulse_ball.help_plural":"La force de r\xe9pulsion est plus grande","upgrades.puck_repulse_ball.name":"Atterrissage en douceur","upgrades.respawn.fullHelp":"Apr\xe8s avoir cass\xe9 deux briques ou plus, lorsque la balle touche le palet, la premi\xe8re brique est remise en place, \xe0 condition que l\'espace soit libre et que la brique ne soit pas une bombe.\\n\\nDes effets de particules vous indiqueront o\xf9 les briques appara\xeetront. \\n\\nEn montant en niveau, vous pouvez faire r\xe9appara\xeetre jusqu\'\xe0 4 briques \xe0 la fois, mais il doit toujours y en avoir au moins une qui reste d\xe9truite.","upgrades.respawn.help":"Certaines briques r\xe9apparaissent apr\xe8s avoir \xe9t\xe9 d\xe9truites.","upgrades.respawn.help_plural":"Plus de briques peuvent r\xe9appara\xeetre","upgrades.respawn.name":"R\xe9apparition ","upgrades.right_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9, ce qui vous permet d\'obtenir une pi\xe8ce de plus \xe0 chaque fois que vous cassez les briques suivantes.\\n\\nCependant, votre combinaison se r\xe9initialise d\xe8s que votre balle touche le c\xf4t\xe9 droit de la zone de jeu.\\n\\nD\xe8s que votre combo augmente, le c\xf4t\xe9 droit devient rouge pour vous rappeler que vous devez \xe9viter de le frapper.\\n\\nL\'effet se cumule avec d\'autres avantages de combo, le combo augmente plus rapidement avec plus d\'am\xe9liorations, mais il se r\xe9initialise \xe9galement si l\'une des conditions de r\xe9initialisation est remplie.","upgrades.right_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le c\xf4t\xe9 droit.","upgrades.right_is_lava.name":"\xc9viter le c\xf4t\xe9 droit","upgrades.sapper.fullHelp":"Au lieu de dispara\xeetre, la premi\xe8re brique cass\xe9e est remplac\xe9e par une bombe. Faire rebondir la balle sur le palet r\xe9arme l\'effet. En montant en niveau, vous pourrez placer plus de bombes. N\'oubliez pas que les bombes ont un impact sur la vitesse des pi\xe8ces \xe0 proximit\xe9. Trop d\'explosions peuvent rendre difficile la r\xe9cup\xe9ration des fruits de votre dur labeur.","upgrades.sapper.help":"La premi\xe8re brique cass\xe9e devient une bombe.","upgrades.sapper.help_plural":"Les premi\xe8res briques {{lvl}} cass\xe9es deviennent des bombes.","upgrades.sapper.name":"Sapeur","upgrades.skip_last.fullHelp":"Vous devez casser toutes les briques pour passer au niveau suivant. \\n\\nCependant, il peut \xeatre difficile d\'obtenir les derni\xe8res briques.\\n\\nTerminer un niveau plus t\xf4t permet d\'obtenir des choix suppl\xe9mentaires lors de la mise \xe0 niveau. \\n\\nNe jamais manquer de briques est \xe9galement tr\xe8s avantageux.\\n\\nDonc, si vous avez du mal \xe0 casser les derni\xe8res briques, obtenir cet avantage plusieurs fois peut vous aider.","upgrades.skip_last.help":"La derni\xe8re brique s\'autod\xe9truit.","upgrades.skip_last.help_plural":"Les {{lvl}} derni\xe8res briques restantes s\'autod\xe9truiront","upgrades.skip_last.name":"Nettoyage facile","upgrades.slow_down.fullHelp":"La balle d\xe9marre relativement lentement, mais \xe0 chaque niveau de votre course, elle d\xe9marre un peu plus vite, et elle acc\xe9l\xe8re \xe9galement si vous passez beaucoup de temps dans un niveau.\\n\\nCet avantage rend la balle plus facile \xe0 g\xe9rer. \\n\\nVous pouvez l\'obtenir au d\xe9but de chaque course en activant le mode enfant dans le menu.","upgrades.slow_down.help":"La balle se d\xe9place plus lentement","upgrades.slow_down.name":"Balle lente","upgrades.smaller_puck.fullHelp":"Le palet est donc plus petit, ce qui, en th\xe9orie, facilite certains tirs en coin, mais augmente surtout la difficult\xe9.\\n\\nC\'est pourquoi vous b\xe9n\xe9ficiez \xe9galement d\'un bonus de +5 pi\xe8ces par brique pour toutes les briques que vous casserez apr\xe8s avoir choisi cette option.","upgrades.smaller_puck.help":"Donne aussi +5 combo","upgrades.smaller_puck.help_plural":"Palet encore plus petit et combinaison de base plus \xe9lev\xe9e","upgrades.smaller_puck.name":"Palet plus petit","upgrades.soft_reset.fullHelp":"Le combo monte normalement \xe0 chaque fois que vous cassez une brique. Ceci annulera parfois cette mont\xe9e, mais limitera \xe9galement l\'impact d\'une r\xe9initialisation du combo.","upgrades.soft_reset.help":"Le combo cro\xeet plus lentement mais se r\xe9initialise moins","upgrades.soft_reset.name":"R\xe9initialisation progressive","upgrades.streak_shots.fullHelp":"Chaque fois que vous cassez une brique, votre combo (nombre de pi\xe8ces par brique) augmente d\'une unit\xe9. Cependant, d\xe8s que la balle touche votre palet, le combo est remis \xe0 sa valeur par d\xe9faut, et vous n\'obtiendrez qu\'une seule pi\xe8ce par brique.\\n\\nUne fois que votre combinaison d\xe9passe la valeur de base, votre palet devient rouge pour vous rappeler que le fait de le toucher avec la balle d\xe9truira votre combinaison.\\n\\nCela peut se cumuler avec d\'autres avantages li\xe9s au combo, le combo augmentera plus rapidement mais se r\xe9initialisera plus facilement car n\'importe laquelle des conditions suffit \xe0 le r\xe9initialiser.","upgrades.streak_shots.help":"Plus de pi\xe8ces si vous cassez plusieurs briques \xe0 la fois.","upgrades.streak_shots.name":"S\xe9quence de destruction","upgrades.sturdy_bricks.fullHelp":"Avec le niveau 1 de cette comp\xe9tence, la balle a 20 % de chances de rebondir sans casser les briques, mais g\xe9n\xe8re 10% de pi\xe8ces en plus lorsqu\'elle en casse une.\\n\\nCe +10% n\'est pas indiqu\xe9 dans le nombre de combos. Au niveau 4, la balle a 80 % de chances de rebondir et rapporte 40 % de pi\xe8ces en plus.","upgrades.sturdy_bricks.help":"Les briques r\xe9sistent parfois aux coups mais font tomber plus de pi\xe8ces.","upgrades.sturdy_bricks.help_plural":"Les briques r\xe9sistent davantage et font tomber plus de pi\xe8ces","upgrades.sturdy_bricks.name":"Briques solides","upgrades.telekinesis.fullHelp":"D\xe8s que la balle touche votre palet, vous pouvez la diriger vers la gauche ou la droite en d\xe9pla\xe7ant votre palet.\\n\\nL\'effet s\'arr\xeate lorsque la balle touche une brique et se r\xe9initialise la prochaine fois qu\'elle touche le palet. Il ne fait rien non plus lorsque la balle descend apr\xe8s avoir rebondi au sommet.","upgrades.telekinesis.help":"Contr\xf4ler la trajectoire de la balle","upgrades.telekinesis.help_plural":"Effet plus fort sur la balle","upgrades.telekinesis.name":"T\xe9l\xe9kin\xe9sie","upgrades.top_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9. Cependant, votre combo sera r\xe9initialis\xe9 d\xe8s que votre balle atteindra le haut de l\'\xe9cran.\\n\\nLorsque votre combo est sup\xe9rieur au minimum, une barre rouge appara\xeet en haut de l\'\xe9cran pour vous rappeler que vous devez \xe9viter de la frapper.\\n\\nCet effet s\'ajoute aux autres avantages du combo.","upgrades.top_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le sommet.","upgrades.top_is_lava.name":"Icare","upgrades.viscosity.fullHelp":"Les pi\xe8ces acc\xe9l\xe8rent normalement avec la gravit\xe9 et les explosions pour atteindre des vitesses assez \xe9lev\xe9es. \\n\\nCette comp\xe9tence les ralentit constamment, comme si elles se trouvaient dans une sorte de liquide visqueux.\\n\\nCela permet de les attraper plus facilement et se combine bien avec les am\xe9liorations qui influencent le mouvement de la pi\xe8ce.","upgrades.viscosity.help":"Chute plus lente des pi\xe8ces","upgrades.viscosity.name":"Fluide visqueux ","upgrades.wind.fullHelp":"Le vent d\xe9pend de l\'endroit o\xf9 se trouve le palet, s\'il est au centre de l\'\xe9cran, il ne se passe rien, s\'il est \xe0 gauche, il soufflera vers la gauche, s\'il est \xe0 droite de l\'\xe9cran, il soufflera vers la droite.\\n\\nLe vent affecte \xe0 la fois les balles et les pi\xe8ces.","upgrades.wind.help":"La position du palet cr\xe9e du vent","upgrades.wind.help_plural":"Force du vent plus importante","upgrades.wind.name":"Vive le vent"}'); +module.exports = JSON.parse('{"confirmRestart.no":"Annuler ,continuer ma partie en cours","confirmRestart.text":"Vous \xeates sur le point de commencer une nouvelle partie, est-ce vraiment ce que vous vouliez ?","confirmRestart.title":"D\xe9marrer une nouvelle partie ?","confirmRestart.yes":"Commencer une nouvelle partie","gameOver.cumulative_total":"Votre score total cumul\xe9 est pass\xe9 de {{startTs}} \xe0 {{endTs}}.","gameOver.lost.summary":"Vous avez fait tomber la balle apr\xe8s avoir attrap\xe9 {{score}} pi\xe8ces.","gameOver.lost.title":"Balle perdue","gameOver.next_unlock":"Marquez {{points}} points suppl\xe9mentaires pour d\xe9bloquer la prochaine am\xe9lioration ou le prochain niveau.","gameOver.restart":"Nouvelle partie","gameOver.stats.balls_lost":"Balles perdues","gameOver.stats.bricks_broken":"Briques cass\xe9es","gameOver.stats.bricks_per_minute":"Briques cass\xe9es par minute","gameOver.stats.catch_rate":"Taux de capture des pi\xe8ces","gameOver.stats.combo_avg":"Combo moyen","gameOver.stats.combo_max":"Combo maximum","gameOver.stats.duration_per_level":"Dur\xe9e par niveau","gameOver.stats.hit_rate":"Pr\xe9cision","gameOver.stats.intro":"Vous trouverez ci-dessous les statistiques de cette partie compar\xe9es \xe0 vos {{count}} meilleures parties.","gameOver.stats.level_reached":"Niveau atteint","gameOver.stats.total_score":"Score total","gameOver.stats.upgrades_applied":"Mises \xe0 jour appliqu\xe9es","gameOver.test_run":"Cette partie de test et son score ne sont pas enregistr\xe9s.","gameOver.unlocked_count":"Vous avez d\xe9bloqu\xe9 {{count}} objet(s) :","gameOver.win.summary":"Vous avez nettoy\xe9 tous les niveaux pour cette partie, en attrapant {{score}} pi\xe8ces au total.","gameOver.win.title":"Partie termin\xe9e","level_up.after_buttons":"Vous venez de terminer le niveau {{level}}/{{max}} et vous avez choisi ces am\xe9liorations jusqu\'\xe0 pr\xe9sent :","level_up.before_buttons":"Vous avez attrap\xe9 {{score}} pi\xe8ces {{catchGain}} sur {{levelSpawnedCoins}} en {{time}} secondes ${timeGain}.\\n\\nVous avez rat\xe9 les briques {{levelMisses}} fois {{missesGain}}.\\n\\n{{compliment}}","level_up.compliment_advice":"Essayez d\'attraper toutes les pi\xe8ces, de ne jamais rater les briques ou de terminer le niveau en moins de 30 secondes pour obtenir des choix suppl\xe9mentaires et des am\xe9liorations.","level_up.compliment_good":"Bravo !","level_up.compliment_perfect":"Impressionnant, continuez comme \xe7a !","level_up.pick_upgrade_title":"Choisir une am\xe9lioration","level_up.plus_one_choice":"(+1 choix)","level_up.plus_one_upgrade":"(+1 am\xe9lioration et +1 choix)","level_up.unlocked_level":" (Niveau)","level_up.unlocked_perk":" (Am\xe9lioration)","level_up.upgrade_perk_to_level":" niveau {{level}}","main_menu.basic":"Graphismes simplifi\xe9s","main_menu.basic_help":"Moins de particules et effets, meilleures performances.","main_menu.footer_html":"

Programm\xe9 en France par Renan LE CARO. Politique de confidentialit\xe9 F-Droid Google Play itch.io Gitlab Version web HackerNews v.{{appVersion}}

","main_menu.fullscreen":"Plein \xe9cran","main_menu.fullscreen_exit":"Quitter le plein \xe9cran","main_menu.fullscreen_exit_help":"Peut ne pas fonctionner sur certaines machines","main_menu.fullscreen_help":"Peut ne pas fonctionner sur certaines machines","main_menu.kid":"Mode enfants","main_menu.kid_help":"Balle plus lente","main_menu.language":"Langue","main_menu.language_help":"Changer la langue d\'affichage","main_menu.mobile":"Mode mobile","main_menu.mobile_help":"Laisse un espace pour le pouce sous le palet.","main_menu.pointer_lock":"Verrouillage du pointeur de la souris","main_menu.pointer_lock_help":"Verrouille et cache le curseur de la souris.","main_menu.record":"Enregistrer des vid\xe9os de jeu","main_menu.record_download":"T\xe9l\xe9charger la vid\xe9o ({{size}} MB)","main_menu.record_help":"Obtenez une vid\xe9o de chaque niveau.","main_menu.reset":"R\xe9initialiser le jeu","main_menu.reset_cancel":"Non","main_menu.reset_confirm":"Oui","main_menu.reset_help":"Effacer le meilleur score et les statistiques","main_menu.reset_instruction":"Vous perdrez tous les progr\xe8s que vous avez faits dans le jeu, \xeates-vous s\xfbr ?","main_menu.resume":"Retourner \xe0 la partie","main_menu.resume_help":"Continuer la partie en cours","main_menu.sounds":"Sons du jeu","main_menu.sounds_help":"Ralentis certains t\xe9l\xe9phones.","main_menu.title":"Breakout 71","main_menu.unlocks":"Am\xe9liorations et niveaux","main_menu.unlocks_help":"Essayez les \xe9l\xe9ments d\xe9bloqu\xe9s","play.close_modale_window_tooltip":"Fermer","play.current_lvl":"Niveau {{level}}/{{max}}","play.menu_label":"Menu","play.missed_ball":"rat\xe9","play.mobile_press_to_play":"Appuyez et maintenez ici pour jouer","sandbox.help":"Tester n\'importe quelle combinaison d\'am\xe9liorations","sandbox.instructions":"S\xe9lectionnez les am\xe9lioration ci-dessous et appuyez sur \\"D\xe9marrer la partie de test\\" pour les tester. Les scores et les statistiques ne seront pas enregistr\xe9s.","sandbox.start":"D\xe9marrer la partie de test","sandbox.title":"Mode bac \xe0 sable","sandbox.unlocks_at":"D\xe9verrouill\xe9 \xe0 partir d\'un score total de ${{score}}","score_panel.restart":"Red\xe9marrer","score_panel.restart_help":"Commencer une nouvelle partie","score_panel.resume":"Continuer la partie","score_panel.resume_help":"Fermer cette fen\xeatre pour retourner au jeu","score_panel.test_run":"Il s\'agit d\'une partie d\'essai, le score n\'est pas enregistr\xe9.","score_panel.title":"{{score}} points au niveau {{level}}/{{max}} ","score_panel.upgrades_picked":"Am\xe9liorations choisies jusqu\'\xe0 pr\xe9sent :","unlocks.greyed_out_help":"Les \xe9l\xe9ments gris\xe9es peuvent \xeatre d\xe9bloqu\xe9es en augmentant votre score total. Le score total augmente \xe0 chaque fois que vous marquez des points dans le jeu.","unlocks.intro":"Votre score total est de {{ts}}. Vous trouverez ci-dessous toutes les am\xe9liorations et tous les niveaux que le jeu peut offrir.","unlocks.level_description":"Un niveau {{size}}x{{size}} avec {{bricks}} briques","unlocks.title":"Vous avez d\xe9bloqu\xe9 {{percentUnlock}}% du jeu.","unlocks.unlocks_at":"D\xe9verrouill\xe9 au score total {{threshold}}.","upgrades.ball_attract_ball.fullHelp":"Les balles qui sont \xe9loign\xe9es de plus d\'une demi-largeur d\'\xe9cran commencent \xe0 s\'attirer. La force d\'attraction est plus forte lorsque les balles sont plus \xe9loign\xe9es l\'une de l\'autre. Des particules arc-en-ciel voleront pour symboliser la force d\'attraction. Cet avantage n\'est offert que si vous avez d\xe9j\xe0 plus d\'une balle en jeu.","upgrades.ball_attract_ball.help":"Les balles attirent les balles","upgrades.ball_attract_ball.help_plural":"Force d\'attraction plus forte","upgrades.ball_attract_ball.name":"Gravit\xe9","upgrades.ball_repulse_ball.fullHelp":"Les balles qui se trouvent \xe0 moins d\'une demi-largeur d\'\xe9cran commencent \xe0 se repousser les unes les autres. La force de r\xe9pulsion est plus forte si elles sont proches l\'une de l\'autre. Des particules seront affich\xe9es pour symboliser l\'application de cette force. Cet avantage n\'est offert que si vous avez d\xe9j\xe0 plus d\'une balle.","upgrades.ball_repulse_ball.help":"Les balles repoussent les balles","upgrades.ball_repulse_ball.help_plural":"Force de r\xe9pulsion plus forte","upgrades.ball_repulse_ball.name":"Vol en formation","upgrades.base_combo.fullHelp":"Votre combo (nombre de pi\xe8ces par brique) commence normalement \xe0 1 au d\xe9but du niveau et revient \xe0 1 lorsque vous rebondissez sans rien toucher. Avec cette caract\xe9ristique, le combo commence 3 points plus haut, ce qui fait que vous obtiendrez toujours au moins 4 pi\xe8ces par brique. Lorsque votre combo est r\xe9initialis\xe9, il revient \xe0 4 et non \xe0 1. Votre balle scintillera un peu pour indiquer que son combo est sup\xe9rieur \xe0 1.","upgrades.base_combo.help":"Le combo commence \xe0 {{coins}}.","upgrades.base_combo.name":"Combo +3","upgrades.bigger_explosions.fullHelp":"L\'explosion par d\xe9faut efface un carr\xe9 de 3x3 briques, avec cette am\xe9lioration un carr\xe9 de 5x5. Le vent soufflant les pi\xe8ces est \xe9galement beaucoup plus fort. L\'\xe9cran clignotera un peu apr\xe8s chaque explosion (sauf en mode graphismes basiques).","upgrades.bigger_explosions.help":"Explosions plus violentes","upgrades.bigger_explosions.name":"Kaboom","upgrades.bigger_puck.fullHelp":"Un grand palet permet de ne jamais rater la balle et d\'attraper plus de pi\xe8ces, ainsi que d\'orienter pr\xe9cis\xe9ment les rebonds (l\'angle de la balle ne d\xe9pend que de l\'endroit o\xf9 elle touche le palet). Cependant, un grand palet est plus difficile \xe0 utiliser sur les c\xf4t\xe9s du niveau.","upgrades.bigger_puck.help":"Attrapez facilement plus de pi\xe8ces.","upgrades.bigger_puck.name":"Palet plus grand","upgrades.coin_magnet.fullHelp":"Dirige les pi\xe8ces vers le palet. L\'effet est plus fort si la pi\xe8ce est d\xe9j\xe0 proche du palet. Attraper 90 % ou 100 % des pi\xe8ces apporte des bonus sp\xe9ciaux dans le jeu. Une autre fa\xe7on d\'attraper plus de pi\xe8ces est de frapper les briques par le bas. La vitesse et la direction de la balle ont un impact sur la vitesse des pi\xe8ces produites.","upgrades.coin_magnet.help":"Le palet attire les pi\xe8ces","upgrades.coin_magnet.help_plural":"Effet plus marqu\xe9 sur les pi\xe8ces","upgrades.coin_magnet.name":"Aimant pour pi\xe8ces","upgrades.compound_interest.fullHelp":"Votre combo augmentera d\'une unit\xe9 \xe0 chaque fois que vous casserez une brique, g\xe9n\xe9rant de plus en plus de pi\xe8ces \xe0 chaque fois que vous casserez une brique. Veillez cependant \xe0 attraper chacune de ces pi\xe8ces avec votre palet, car toute pi\xe8ce perdue remettra votre combo \xe0 z\xe9ro. \\n \\nSi votre combinaison est sup\xe9rieure au minimum, une ligne rouge s\'affichera au bas de la zone de jeu pour vous le rappeler que les pi\xe8ces ne doivent pas aller \xe0 cet endroit.\\n\\nCet avantage se combine avec d\'autres avantages de combo, le combo augmentera plus rapidement mais se r\xe9initialisera plus souvent.","upgrades.compound_interest.help":"+1 combo par brique cass\xe9e, remise \xe0 z\xe9ro quand une pi\xe8ce est perdu","upgrades.compound_interest.name":"Int\xe9r\xeats","upgrades.extra_levels.fullHelp":"La partie dure normalement 7 niveaux, apr\xe8s quoi le jeu est termin\xe9 et le score que vous avez atteint est votre score de partie.\\n\\nChoisir cette am\xe9lioration vous permet de prolonger la partie d\'un niveau. Les derniers niveaux sont souvent ceux o\xf9 vous faites le plus de points, la diff\xe9rence peut donc \xeatre spectaculaire.","upgrades.extra_levels.help":"Jouer {{count}} niveaux au lieu de 7","upgrades.extra_levels.name":"+1 niveau","upgrades.extra_life.fullHelp":"Normalement, vous n\'avez qu\'une seule balle par manche, et la manche est termin\xe9e d\xe8s que vous la laissez tomber.\\nCette comp\xe9tence ajoute une barre blanche en bas de l\'\xe9cran qui sauvera une balle une fois, et se brisera au cours du processus.\\nVous pouvez prendre plusieurs vies d\'avances, elle seront utilis\xe9es \xe0 chaque fois qu\'une balle est sur le point d\'\xeatre perdue. ","upgrades.extra_life.help":"La balle rebondit une fois avant d\'\xeatre perdue.","upgrades.extra_life.help_plural":"La balle rebondit {{lvl}} fois avant d\'\xeatre perdue.","upgrades.extra_life.name":"+1 vie","upgrades.hot_start.fullHelp":"Au d\xe9but de chaque niveau, votre combo commencera \xe0 +15 points, mais \xe0 chaque seconde, il sera diminu\xe9 d\'un point. Cela signifie que les 15 premi\xe8res secondes d\'un niveau produiront beaucoup plus de pi\xe8ces que les suivantes.\\nVous devez vous assurer de terminer le niveau rapidement. L\'effet se cumule avec d\'autres avantages li\xe9s au combo, ce qui vous permet d\'augmenter le combo apr\xe8s les 15 secondes, mais il continuera \xe0 diminuer chaque seconde. Chaque fois que vous reprenez la comp\xe9tence, l\'effet est encore plus prononc\xe9.","upgrades.hot_start.help":"Combo \xe0 {{start}}, -{{lvl}} combo par seconde","upgrades.hot_start.name":"D\xe9marrage \xe0 chaud","upgrades.instant_upgrade.fullHelp":"Choisissez imm\xe9diatement deux am\xe9liorations, afin d\'en obtenir une gratuite et une autre pour rembourser celle utilis\xe9e pour obtenir cet avantage. Chaque fois que vous choisirez des am\xe9liorations dans le menu suivant, vous aurez moins de choix.","upgrades.instant_upgrade.help":"-1 choix jusqu\'\xe0 la fin de la course.","upgrades.instant_upgrade.name":"+2 am\xe9liorations maintenant","upgrades.left_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9, ce qui vous permet d\'obtenir une pi\xe8ce de plus \xe0 chaque fois que vous cassez une brique.\\n\\nCependant, votre combinaison se r\xe9initialise d\xe8s que votre balle touche le c\xf4t\xe9 gauche.\\n\\nD\xe8s que votre combo augmente, le c\xf4t\xe9 gauche devient rouge pour vous rappeler que vous devez \xe9viter de le frapper.\\n\\nL\'effet se cumule avec d\'autres avantages de combo, le combo augmente plus rapidement avec plus d\'am\xe9liorations, mais il se r\xe9initialise \xe9galement si l\'une ou l\'autre des conditions de r\xe9initialisation est remplie. ","upgrades.left_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le c\xf4t\xe9 gauche.","upgrades.left_is_lava.name":"\xc9viter le c\xf4t\xe9 gauche","upgrades.metamorphosis.fullHelp":"Avec cette am\xe9lioration, les pi\xe8ces seront de la couleur de la brique d\'o\xf9 elles proviennent et coloreront la premi\xe8re brique qu\'elles toucheront. \\n\\nLes pi\xe8ces apparaissent \xe0 la vitesse de la balle qui les a cass\xe9es, ce qui signifie que vous pouvez viser un peu dans la direction des briques que vous voulez \\"peindre\\".","upgrades.metamorphosis.help":"Les pi\xe8ces de monnaie tachent les briques qu\'elles touchent","upgrades.metamorphosis.name":"M\xe9tamorphose","upgrades.multiball.fullHelp":"D\xe8s que vous laissez tomber la balle dans Breakout 71, vous perdez. \\n\\nAvec cet avantage, vous obtenez deux balles, et vous pouvez donc vous permettre d\'en perdre une.\\n\\nLes balles perdues reviennent au niveau suivant. \\n\\nLe fait d\'avoir plus d\'une balle permet d\'obtenir d\'autres avantages et, bien s\xfbr, de franchir le niveau plus rapidement.","upgrades.multiball.help":"Chaque niveau commence avec {{count}} balles.","upgrades.multiball.name":"+1 balle","upgrades.one_more_choice.fullHelp":"Chaque menu d\'am\xe9lioration comportera une option suppl\xe9mentaire. Cela n\'augmente pas le nombre d\'am\xe9liorations que vous pouvez choisir, mais vous aide \xe0 cr\xe9er le profile id\xe9al. ","upgrades.one_more_choice.help":"Les niveaux suivants offriront une option suppl\xe9mentaire dans la liste d\'am\xe9liorations.","upgrades.one_more_choice.name":"+1 choix jusqu\'\xe0 la fin de la course","upgrades.picky_eater.fullHelp":"Chaque fois que vous cassez une brique de la m\xeame couleur que votre balle, votre combo augmente d\'une unit\xe9.\\n\\nS\'il s\'agit d\'une couleur diff\xe9rente, la balle adopte cette nouvelle couleur, mais la combinaison est r\xe9initialis\xe9e.\\n\\nLes briques de la mauvaise couleur sont entour\xe9es en rouge.\\n\\nSi vous avez plus d\'une balle, elles changent toutes de couleur lorsque l\'une d\'entre elles touche une brique.","upgrades.picky_eater.help":"Plus de pi\xe8ces si vous cassez les briques couleur par couleur.","upgrades.picky_eater.name":"Mangeur par couleur","upgrades.pierce.fullHelp":"Normalement , la balle rebondit d\xe8s qu\'elle touche une brique. Avec cette caract\xe9ristique, elle continuera sa trajectoire jusqu\'\xe0 3 briques cass\xe9es.\\n\\nApr\xe8s cela, elle rebondira sur la quatri\xe8me brique et vous devez toucher le palet pour remettre le compteur \xe0 z\xe9ro.","upgrades.pierce.help":"La balle perce {{count}} briques apr\xe8s chaque rebond sur le palet","upgrades.pierce.name":"Balle per\xe7ante","upgrades.pierce_color.fullHelp":"Chaque fois qu\'une balle touche une brique de la m\xeame couleur, elle la traverse sans encombre.\\nLorsqu\'elle atteint une brique de couleur diff\xe9rente, elle la casse, prend sa couleur et rebondit.","upgrades.pierce_color.help":"Les balles transpercent les briques de leur couleur","upgrades.pierce_color.name":"Perceur de couleur","upgrades.puck_repulse_ball.fullHelp":"Lorsqu\'une balle s\'approche du palet, elle commence \xe0 ralentir, voire \xe0 rebondir sans toucher le palet. Beaucoup de choses sont li\xe9es \xe0 un passage par le palet dans le jeu, donc \xe7a pourrait ouvrir des possibilit\xe9s. ","upgrades.puck_repulse_ball.help":"Le palet repousse les balles","upgrades.puck_repulse_ball.help_plural":"La force de r\xe9pulsion est plus grande","upgrades.puck_repulse_ball.name":"Atterrissage en douceur","upgrades.respawn.fullHelp":"Apr\xe8s avoir cass\xe9 deux briques ou plus, lorsque la balle touche le palet, la premi\xe8re brique est remise en place, \xe0 condition que l\'espace soit libre et que la brique ne soit pas une bombe.\\n\\nDes effets de particules vous indiqueront o\xf9 les briques appara\xeetront. \\n\\nEn montant en niveau, vous pouvez faire r\xe9appara\xeetre jusqu\'\xe0 4 briques \xe0 la fois, mais il doit toujours y en avoir au moins une qui reste d\xe9truite.","upgrades.respawn.help":"Certaines briques r\xe9apparaissent apr\xe8s avoir \xe9t\xe9 d\xe9truites.","upgrades.respawn.help_plural":"Plus de briques peuvent r\xe9appara\xeetre","upgrades.respawn.name":"R\xe9apparition ","upgrades.right_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9, ce qui vous permet d\'obtenir une pi\xe8ce de plus \xe0 chaque fois que vous cassez les briques suivantes.\\n\\nCependant, votre combinaison se r\xe9initialise d\xe8s que votre balle touche le c\xf4t\xe9 droit de la zone de jeu.\\n\\nD\xe8s que votre combo augmente, le c\xf4t\xe9 droit devient rouge pour vous rappeler que vous devez \xe9viter de le frapper.\\n\\nL\'effet se cumule avec d\'autres avantages de combo, le combo augmente plus rapidement avec plus d\'am\xe9liorations, mais il se r\xe9initialise \xe9galement si l\'une des conditions de r\xe9initialisation est remplie.","upgrades.right_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le c\xf4t\xe9 droit.","upgrades.right_is_lava.name":"\xc9viter le c\xf4t\xe9 droit","upgrades.sapper.fullHelp":"Au lieu de dispara\xeetre, la premi\xe8re brique cass\xe9e est remplac\xe9e par une bombe. Faire rebondir la balle sur le palet r\xe9arme l\'effet. En montant en niveau, vous pourrez placer plus de bombes. N\'oubliez pas que les bombes ont un impact sur la vitesse des pi\xe8ces \xe0 proximit\xe9. Trop d\'explosions peuvent rendre difficile la r\xe9cup\xe9ration des fruits de votre dur labeur.","upgrades.sapper.help":"La premi\xe8re brique cass\xe9e devient une bombe.","upgrades.sapper.help_plural":"Les premi\xe8res briques {{lvl}} cass\xe9es deviennent des bombes.","upgrades.sapper.name":"Sapeur","upgrades.skip_last.fullHelp":"Vous devez casser toutes les briques pour passer au niveau suivant. \\n\\nCependant, il peut \xeatre difficile d\'obtenir les derni\xe8res briques.\\n\\nTerminer un niveau plus t\xf4t permet d\'obtenir des choix suppl\xe9mentaires lors de la mise \xe0 niveau. \\n\\nNe jamais manquer de briques est \xe9galement tr\xe8s avantageux.\\n\\nDonc, si vous avez du mal \xe0 casser les derni\xe8res briques, obtenir cet avantage plusieurs fois peut vous aider.","upgrades.skip_last.help":"La derni\xe8re brique s\'autod\xe9truit.","upgrades.skip_last.help_plural":"Les {{lvl}} derni\xe8res briques restantes s\'autod\xe9truiront","upgrades.skip_last.name":"Nettoyage facile","upgrades.slow_down.fullHelp":"La balle d\xe9marre relativement lentement, mais \xe0 chaque niveau de votre course, elle d\xe9marre un peu plus vite, et elle acc\xe9l\xe8re \xe9galement si vous passez beaucoup de temps dans un niveau.\\n\\nCet avantage rend la balle plus facile \xe0 g\xe9rer. \\n\\nVous pouvez l\'obtenir au d\xe9but de chaque course en activant le mode enfant dans le menu.","upgrades.slow_down.help":"La balle se d\xe9place plus lentement","upgrades.slow_down.name":"Balle lente","upgrades.smaller_puck.fullHelp":"Le palet est donc plus petit, ce qui, en th\xe9orie, facilite certains tirs en coin, mais augmente surtout la difficult\xe9.\\n\\nC\'est pourquoi vous b\xe9n\xe9ficiez \xe9galement d\'un bonus de +5 pi\xe8ces par brique pour toutes les briques que vous casserez apr\xe8s avoir choisi cette option.","upgrades.smaller_puck.help":"Donne aussi +5 combo","upgrades.smaller_puck.help_plural":"Palet encore plus petit et combinaison de base plus \xe9lev\xe9e","upgrades.smaller_puck.name":"Palet plus petit","upgrades.soft_reset.fullHelp":"Limite l\'impact d\'une r\xe9initialisation du combo.","upgrades.soft_reset.help":"La remise \xe0 z\xe9ro du combo conserve {{percent}}% des points","upgrades.soft_reset.name":"R\xe9initialisation progressive","upgrades.streak_shots.fullHelp":"Chaque fois que vous cassez une brique, votre combo (nombre de pi\xe8ces par brique) augmente d\'une unit\xe9. Cependant, d\xe8s que la balle touche votre palet, le combo est remis \xe0 sa valeur par d\xe9faut, et vous n\'obtiendrez qu\'une seule pi\xe8ce par brique.\\n\\nUne fois que votre combinaison d\xe9passe la valeur de base, votre palet devient rouge pour vous rappeler que le fait de le toucher avec la balle d\xe9truira votre combinaison.\\n\\nCela peut se cumuler avec d\'autres avantages li\xe9s au combo, le combo augmentera plus rapidement mais se r\xe9initialisera plus facilement car n\'importe laquelle des conditions suffit \xe0 le r\xe9initialiser.","upgrades.streak_shots.help":"Plus de pi\xe8ces si vous cassez plusieurs briques \xe0 la fois.","upgrades.streak_shots.name":"S\xe9quence de destruction","upgrades.sturdy_bricks.fullHelp":"Avec le niveau 1 de cette comp\xe9tence, la balle a 20 % de chances de rebondir sans casser les briques, mais g\xe9n\xe8re 10% de pi\xe8ces en plus lorsqu\'elle en casse une.\\n\\nCe +10% n\'est pas indiqu\xe9 dans le nombre de combos. Au niveau 4, la balle a 80 % de chances de rebondir et rapporte 40 % de pi\xe8ces en plus.","upgrades.sturdy_bricks.help":"Les briques r\xe9sistent parfois aux coups mais font tomber plus de pi\xe8ces.","upgrades.sturdy_bricks.help_plural":"Les briques r\xe9sistent davantage et font tomber plus de pi\xe8ces","upgrades.sturdy_bricks.name":"Briques solides","upgrades.telekinesis.fullHelp":"D\xe8s que la balle touche votre palet, vous pouvez la diriger vers la gauche ou la droite en d\xe9pla\xe7ant votre palet.\\n\\nL\'effet s\'arr\xeate lorsque la balle touche une brique et se r\xe9initialise la prochaine fois qu\'elle touche le palet. Il ne fait rien non plus lorsque la balle descend apr\xe8s avoir rebondi au sommet.","upgrades.telekinesis.help":"Contr\xf4ler la trajectoire de la balle","upgrades.telekinesis.help_plural":"Effet plus fort sur la balle","upgrades.telekinesis.name":"T\xe9l\xe9kin\xe9sie","upgrades.top_is_lava.fullHelp":"Chaque fois que vous cassez une brique, votre combo augmente d\'une unit\xe9. Cependant, votre combo sera r\xe9initialis\xe9 d\xe8s que votre balle atteindra le haut de l\'\xe9cran.\\n\\nLorsque votre combo est sup\xe9rieur au minimum, une barre rouge appara\xeet en haut de l\'\xe9cran pour vous rappeler que vous devez \xe9viter de la frapper.\\n\\nCet effet s\'ajoute aux autres avantages du combo.","upgrades.top_is_lava.help":"Plus de pi\xe8ces si vous ne touchez pas le sommet.","upgrades.top_is_lava.name":"Icare","upgrades.viscosity.fullHelp":"Les pi\xe8ces acc\xe9l\xe8rent normalement avec la gravit\xe9 et les explosions pour atteindre des vitesses assez \xe9lev\xe9es. \\n\\nCette comp\xe9tence les ralentit constamment, comme si elles se trouvaient dans une sorte de liquide visqueux.\\n\\nCela permet de les attraper plus facilement et se combine bien avec les am\xe9liorations qui influencent le mouvement de la pi\xe8ce.","upgrades.viscosity.help":"Chute plus lente des pi\xe8ces","upgrades.viscosity.name":"Fluide visqueux ","upgrades.wind.fullHelp":"Le vent d\xe9pend de l\'endroit o\xf9 se trouve le palet, s\'il est au centre de l\'\xe9cran, il ne se passe rien, s\'il est \xe0 gauche, il soufflera vers la gauche, s\'il est \xe0 droite de l\'\xe9cran, il soufflera vers la droite.\\n\\nLe vent affecte \xe0 la fois les balles et les pi\xe8ces.","upgrades.wind.help":"La position du palet cr\xe9e du vent","upgrades.wind.help_plural":"Force du vent plus importante","upgrades.wind.name":"Vive le vent"}'); },{}],"uYc9N":[function(require,module,exports,__globalThis) { -module.exports = JSON.parse("{\"confirmRestart.no\":\"Cancel\",\"confirmRestart.text\":\"You're about to start a new run, is that really what you wanted ?\",\"confirmRestart.title\":\"Start a new run ?\",\"confirmRestart.yes\":\"Restart game\",\"gameOver.cumulative_total\":\"Your total cumulative score went from {{startTs}} to {{endTs}}.\",\"gameOver.lost.summary\":\"You dropped the ball after catching {{score}} coins.\",\"gameOver.lost.title\":\"Game Over\",\"gameOver.next_unlock\":\"Score {{points}} more points to reach the next unlock\",\"gameOver.restart\":\"Start a new run\",\"gameOver.stats.balls_lost\":\"Balls lost\",\"gameOver.stats.bricks_broken\":\"Bricks broken\",\"gameOver.stats.bricks_per_minute\":\"Bricks broken per minute\",\"gameOver.stats.catch_rate\":\"Catch rate\",\"gameOver.stats.combo_avg\":\"Average combo\",\"gameOver.stats.combo_max\":\"Max combo\",\"gameOver.stats.duration_per_level\":\"Duration per level\",\"gameOver.stats.hit_rate\":\"Hit rate\",\"gameOver.stats.intro\":\"Find below your run statistics compared to your {{count}} best runs.\",\"gameOver.stats.level_reached\":\"Level reached\",\"gameOver.stats.total_score\":\"Total score\",\"gameOver.stats.upgrades_applied\":\"Upgrades applied\",\"gameOver.test_run\":\"This test run and its score are not being recorded\",\"gameOver.unlocked_count\":\"You unlocked {{count}} item(s) :\",\"gameOver.win.summary\":\"You cleared all levels for this run, catching {{score}} coins in total.\",\"gameOver.win.title\":\"Run finished\",\"level_up.after_buttons\":\"You just finished level {{level}}/{{max}} and picked those upgrades so far :\",\"level_up.before_buttons\":\"You caught {{score}} coins {{catchGain}} out of {{levelSpawnedCoins}} in {{time}} seconds ${timeGain}.\\n\\nYou missed {{levelMisses}} times {{missesGain}}.\\n\\n{{compliment}}\",\"level_up.compliment_advice\":\"Try to catch all coins, never miss the bricks or clear the level under 30s to gain additional choices and upgrades.\",\"level_up.compliment_good\":\"Well done !\",\"level_up.compliment_perfect\":\"Impressive, keep it up !\",\"level_up.pick_upgrade_title\":\"Pick an upgrade\",\"level_up.plus_one_choice\":\"(+1 choice)\",\"level_up.plus_one_upgrade\":\"(+1 upgrade and choice)\",\"level_up.unlocked_level\":\" (Level)\",\"level_up.unlocked_perk\":\" (Perk)\",\"level_up.upgrade_perk_to_level\":\" lvl {{level}}\",\"main_menu.basic\":\"Basic graphics\",\"main_menu.basic_help\":\"Fewer particles and flashes, better performance.\",\"main_menu.footer_html\":\"

Made in France by Renan LE CARO. \\n Privacy Policy\\n F-Droid\\n Google Play\\n itch.io \\n Gitlab\\n Web version\\n HackerNews\\n v.{{appVersion}}

\",\"main_menu.fullscreen\":\"Fullscreen\",\"main_menu.fullscreen_exit\":\"Exit Fullscreen\",\"main_menu.fullscreen_exit_help\":\"Might not work on some machines\",\"main_menu.fullscreen_help\":\"Might not work on some machines\",\"main_menu.kid\":\"Kids mode\",\"main_menu.kid_help\":\"Start future runs with \\\"slower ball\\\".\",\"main_menu.language\":\"Language\",\"main_menu.language_help\":\"Choose the game's language\",\"main_menu.mobile\":\"Mobile mode\",\"main_menu.mobile_help\":\"Leaves space for your thumb under the puck.\",\"main_menu.pointer_lock\":\"Mouse pointer lock\",\"main_menu.pointer_lock_help\":\"Locks and hides the mouse cursor.\",\"main_menu.record\":\"Record gameplay videos\",\"main_menu.record_download\":\"Download video ({{size}} MB)\",\"main_menu.record_help\":\"Get a video of each level.\",\"main_menu.reset\":\"Reset Game\",\"main_menu.reset_cancel\":\"No\",\"main_menu.reset_confirm\":\"Yes\",\"main_menu.reset_help\":\"Erase high score and statistics\",\"main_menu.reset_instruction\":\"You will loose all progress you made in the game, are you sure ?\",\"main_menu.resume\":\"Resume\",\"main_menu.resume_help\":\"Return to your run\",\"main_menu.sounds\":\"Game sounds\",\"main_menu.sounds_help\":\"Can slow down some phones.\",\"main_menu.title\":\"Breakout 71\",\"main_menu.unlocks\":\"Starting perk\",\"main_menu.unlocks_help\":\"Try perks and levels you unlocked\",\"play.close_modale_window_tooltip\":\"close \",\"play.current_lvl\":\"L{{level}}/{{max}}\",\"play.menu_label\":\"menu\",\"play.missed_ball\":\"miss\",\"play.mobile_press_to_play\":\"Press and hold here to play\",\"sandbox.help\":\"Test any perk combination\",\"sandbox.instructions\":\"Select perks below and press \\\"start run\\\" to try them out in a test run. Scores and stats are not recorded.\",\"sandbox.start\":\"Start test run\",\"sandbox.title\":\"Sandbox mode\",\"sandbox.unlocks_at\":\"Unlocks at total score ${{score}}\",\"score_panel.restart\":\"Restart\",\"score_panel.restart_help\":\"Start a brand new run\",\"score_panel.resume\":\"Resume\",\"score_panel.resume_help\":\"Return to your run\",\"score_panel.test_run\":\"This is a test run, score is not recorded permanently\",\"score_panel.title\":\"{{score}} points at level {{level}}/{{max}} \",\"score_panel.upgrades_picked\":\"Upgrades picked so far : \",\"unlocks.greyed_out_help\":\"The greyed out ones can be unlocked by increasing your total score. The total score increases every time you score in game.\",\"unlocks.intro\":\"Your total score is {{ts}}. Below are all the upgrades and levels the games has to offer.\",\"unlocks.level_description\":\"A {{size}}x{{size}} level with {{bricks}} bricks\",\"unlocks.title\":\"You unlocked {{percentUnlock}}% of the game.\",\"unlocks.unlocks_at\":\"Unlocks at total score {{threshold}}.\",\"upgrades.ball_attract_ball.fullHelp\":\"Balls that are more than half a screen width away will start attracting each other. \\n\\nThe attraction force is stronger when they are furthest away from each other.\\n\\nRainbow particles will fly to symbolize the attraction force. This perk is only offered if you have more than one ball already.\",\"upgrades.ball_attract_ball.help\":\"Balls attract balls\",\"upgrades.ball_attract_ball.help_plural\":\"Stronger attraction force\",\"upgrades.ball_attract_ball.name\":\"Gravity\",\"upgrades.ball_repulse_ball.fullHelp\":\"Balls that are less than half a screen width away will start repulsing each other. The repulsion force is stronger if they are close to each other. Particles will jet out to symbolize this force being applied. This perk is only offered if you have more than one ball already.\",\"upgrades.ball_repulse_ball.help\":\"Balls repulse balls\",\"upgrades.ball_repulse_ball.help_plural\":\"Stronger repulsion force\",\"upgrades.ball_repulse_ball.name\":\"Personal space\",\"upgrades.base_combo.fullHelp\":\"Your combo (number of coins per bricks) normally starts at 1 at the beginning of the level, and resets to one when you bounce around without hitting anything. With this perk, the combo starts 3 points higher, so you'll always get at least 4 coins per brick. Whenever your combo reset, it will go back to 4 and not 1. Your ball will glitter a bit to indicate that its combo is higher than one.\",\"upgrades.base_combo.help\":\"Every brick drops at least {{coins}} coins.\",\"upgrades.base_combo.name\":\"+3 base combo\",\"upgrades.bigger_explosions.fullHelp\":\"The default explosion clears a 3x3 square, with this it becomes a 5x5 square, and the blow on the coins is also significantly stronger. The screen will flash after each explosion (except in basic mode)\",\"upgrades.bigger_explosions.help\":\"Bigger explosions\",\"upgrades.bigger_explosions.name\":\"Kaboom\",\"upgrades.bigger_puck.fullHelp\":\"A bigger puck makes it easier to never miss the ball and to catch more coins, and also to precisely angle the bounces (the ball's angle only depends on where it hits the puck). \\n However, a large puck is harder to use around the sides of the level, and will make it sometimes unavoidable to miss (not hit anything) which comes with downsides. \",\"upgrades.bigger_puck.help\":\"Easily catch more coins.\",\"upgrades.bigger_puck.name\":\"Bigger puck\",\"upgrades.coin_magnet.fullHelp\":\"Directs the coins to the puck. The effect is stronger if the coin is close to it already. Catching 90% or 100% of coins bring special bonuses in the game. \\n\\nAnother way to catch more coins is to hit bricks from the bottom. The ball's speed and direction impacts the spawned coin's velocity. \",\"upgrades.coin_magnet.help\":\"Puck attracts coins\",\"upgrades.coin_magnet.help_plural\":\"Stronger effect on the coins\",\"upgrades.coin_magnet.name\":\"Coins magnet\",\"upgrades.compound_interest.fullHelp\":\"Your combo will grow by one every time you break a brick, spawning more and more coin with every brick you break. \\n\\nBe sure however to catch every one of those coins with your puck, as any lost coin will reset your combo. \\n\\nOnce your combo is above the minimum, the bottom of the play area will have a red line to remind you that coins should not go there.\\n\\nThis perk combines with other combo perks, the combo will rise faster but reset more easily.\",\"upgrades.compound_interest.help\":\"+1 combo per brick broken, resets on coin lost\",\"upgrades.compound_interest.name\":\"Compound interest\",\"upgrades.extra_levels.fullHelp\":\"The default run can last a max of 7 levels, after which the game is over and whatever score you reached is your run score. \\n\\nEach level of this perk lets you go one level higher. The last levels are often the ones where you make the most score, so the difference can be dramatic.\",\"upgrades.extra_levels.help\":\"Play {{count}} levels instead of 7\",\"upgrades.extra_levels.name\":\"+1 level\",\"upgrades.extra_life.fullHelp\":\"Normally, you have one ball per run, and the run is over as soon as you drop it.\\n\\nThis perk adds a white bar at the bottom of the screen that will save a ball once, and break in the process. \\n\\nYou'll loose one level of that perk every time a ball bounces at the bottom of the screen.\",\"upgrades.extra_life.help\":\"The ball will bounce once on the bottom line before being lost.\",\"upgrades.extra_life.help_plural\":\"The ball will bounce on the bottom {{lvl}} times before being lost.\",\"upgrades.extra_life.name\":\"+1 life\",\"upgrades.hot_start.fullHelp\":\"At the start of every level, your combo will start at +15 points, but then every second it will be decreased by one.\\n\\nThis means the first 15 seconds in a level will spawn many more coins than the following ones, and you should make sure that you clear the level quickly. \\n\\nThe effect stacks with other combo related perks, so you might be able to raise the combo after the 15s timeout, but it will keep ticking down. \\n\\nEvery time you take the perk again, the effect will be more dramatic.\",\"upgrades.hot_start.help\":\"Start at combo {{start}}, -{{lvl}} combo per second\",\"upgrades.hot_start.name\":\"Hot start\",\"upgrades.instant_upgrade.fullHelp\":\"Immediately pick two upgrades, so that you get one free one and one to repay the one used to get this perk. Every further menu to pick upgrades will have fewer options to choose from.\",\"upgrades.instant_upgrade.help\":\"-1 choice until run end.\",\"upgrades.instant_upgrade.name\":\"+2 upgrades now\",\"upgrades.left_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\\n\\nHowever, your combo will reset as soon as your ball hits the left side . \\n\\nAs soon as your combo rises, the left side becomes red to remind you that you should avoid hitting them. \\n\\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any of the reset conditions are met.\",\"upgrades.left_is_lava.help\":\"More coins if you don't touch the left side.\",\"upgrades.left_is_lava.name\":\"Avoid left side\",\"upgrades.metamorphosis.fullHelp\":\"With this perk, coins will be of the color of the brick they come from, and will color the first brick they touch in the same color. Coins spawn with the speed of the ball that broke them, which means you can aim a bit in the direction of the bricks you want to \\\"paint\\\".\",\"upgrades.metamorphosis.help\":\"Coins stain the bricks they touch\",\"upgrades.metamorphosis.name\":\"Metamorphosis\",\"upgrades.multiball.fullHelp\":\"As soon as you drop the ball in Breakout 71, you loose. \\n\\nWith this perk, you get two balls, and so you can afford to lose one. \\n\\nThe lost balls come back on the next level. \\n\\nHaving more than one balls makes some further perks available, and of course clears the level faster.\",\"upgrades.multiball.help\":\"Start every levels with {{count}} balls.\",\"upgrades.multiball.name\":\"+1 ball\",\"upgrades.one_more_choice.fullHelp\":\"Every upgrade menu will have one more option. Doesn't increase the number of upgrades you can pick.\",\"upgrades.one_more_choice.help\":\"Further level ups will offer one more option in the list\",\"upgrades.one_more_choice.name\":\"+1 choice until run end\",\"upgrades.picky_eater.fullHelp\":\"Whenever you break a brick the same color as your ball, your combo increases by one. \\nIf it's a different color, the ball takes that new color, but the combo resets.\\nThe bricks with the right color will get a white border. \\nOnce you get a combo higher than your minimum, the bricks of the wrong color will get a red halo. \\nIf you have more than one ball, they all change color whenever one of them hits a brick.\",\"upgrades.picky_eater.help\":\"More coins if you break bricks color by color.\",\"upgrades.picky_eater.name\":\"Picky eater\",\"upgrades.pierce.fullHelp\":\"The ball normally bounces as soon as it touches something. With this perk, it will continue its trajectory for up to 3 bricks broken. \\nAfter that, it will bounce on the 4th brick, and you'll need to touch the puck to reset the counter.\",\"upgrades.pierce.help\":\"Ball pierces {{count}} bricks after a puck bounce\",\"upgrades.pierce.name\":\"Piercing\",\"upgrades.pierce_color.fullHelp\":\"Whenever a ball hits a brick of the same color, it will just go through unimpeded. \\nOnce it reaches a brick of a different color, it will break it, take its color and bounce.\",\"upgrades.pierce_color.help\":\"Balls pierce bricks of their color\",\"upgrades.pierce_color.name\":\"Color pierce\",\"upgrades.puck_repulse_ball.fullHelp\":\"When a ball gets close to the puck, it will start slowing down, and even potentially bouncing without touching the puck.\",\"upgrades.puck_repulse_ball.help\":\"Puck repulses balls\",\"upgrades.puck_repulse_ball.help_plural\":\"Stronger repulsion force\",\"upgrades.puck_repulse_ball.name\":\"Soft landing\",\"upgrades.respawn.fullHelp\":\"After breaking two or more bricks, when the ball hits the puck, the first brick will be put back in place, provided that space is free and the brick wasn't a bomb.\\n\\nSome particle effect will let you know where bricks will appear. Leveling this up lets you re-spawn up to 4 bricks at a time, but there should always be at least one destroyed.\",\"upgrades.respawn.help\":\"The first brick hit of two+ will re-spawn\",\"upgrades.respawn.help_plural\":\"More bricks can re-spawn\",\"upgrades.respawn.name\":\"Re-spawn\",\"upgrades.right_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\\n\\nHowever, your combo will reset as soon as your ball hits the right side . \\n\\nAs soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them\\n\\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any\\nof the reset conditions are met.\",\"upgrades.right_is_lava.help\":\"More coins if you don't touch the right side.\",\"upgrades.right_is_lava.name\":\"Avoid right side\",\"upgrades.sapper.fullHelp\":\"Instead of just disappearing, the first brick you break will be replaced by a bomb brick. \\n\\nBouncing the ball on the puck re-arms the effect. \\n\\nLeveling-up this perk will allow you to place more bombs.\\n\\nRemember that bombs impact the velocity of nearby coins, so too many explosions could make it hard to catch the fruits of your hard work.\",\"upgrades.sapper.help\":\"The first brick broken becomes a bomb.\",\"upgrades.sapper.help_plural\":\"The first {{lvl}} bricks broken become bombs.\",\"upgrades.sapper.name\":\"Sapper\",\"upgrades.skip_last.fullHelp\":\"You need to break all bricks to go to the next level. However, it can be hard to get the last ones. \\n\\nClearing a level early brings extra choices when upgrading. Never missing the bricks is also very beneficial. \\n\\nSo if you find it difficult to break the last bricks, getting this perk a few time can help.\",\"upgrades.skip_last.help\":\"The last brick will self-destruct.\",\"upgrades.skip_last.help_plural\":\"The last {{lvl}} bricks will self-destruct.\",\"upgrades.skip_last.name\":\"Easy Cleanup\",\"upgrades.slow_down.fullHelp\":\"The ball starts relatively slow, but every level of your run it will start a bit faster. \\n\\nIt will also accelerate if you spend a lot of time in one level. \\n\\nThis perk makes it more manageable. \\n\\nYou can get it at the start every time by enabling kid mode in the menu.\",\"upgrades.slow_down.help\":\"Ball moves more slowly\",\"upgrades.slow_down.name\":\"Slower ball\",\"upgrades.smaller_puck.fullHelp\":\"This makes the puck smaller, which in theory makes some corner shots easier, but really just raises the difficulty.\\n\\nThat's why you also get a nice bonus of +5 coins per brick for all bricks you'll break after picking this. \",\"upgrades.smaller_puck.help\":\"Also gives +5 base combo\",\"upgrades.smaller_puck.help_plural\":\"Even smaller puck and higher base combo\",\"upgrades.smaller_puck.name\":\"Smaller puck\",\"upgrades.soft_reset.fullHelp\":\"The combo normally climbs every time you break a brick. This will sometimes cancel that climb, but also limit the impact of a combo reset.\",\"upgrades.soft_reset.help\":\"Combo grows slower but resets less\",\"upgrades.soft_reset.name\":\"Soft reset\",\"upgrades.streak_shots.fullHelp\":\"Every time you break a brick, your combo (number of coins per bricks) increases by one. \\n\\nHowever, as soon as the ball touches your puck, the combo is reset to its default value, and you'll just get one coin per brick.\\n\\nOnce your combo rises above the base value, your puck will become red to remind you that it will destroy your combo to touch it with the ball.\\n\\nThis can stack with other combo related perks, the combo will rise faster but reset more easily as any of the conditions is enough to reset it. \",\"upgrades.streak_shots.help\":\"More coins if you break many bricks at once.\",\"upgrades.streak_shots.name\":\"Single puck hit streak\",\"upgrades.sturdy_bricks.fullHelp\":\"With level one of this perk, the ball has a 20% chance to bounce harmlessly on bricks, \\n but generates 10% more coins when it does break one. \\n This +10% is not shown in the combo number. At level 4 the ball has 80% chance of bouncing and brings 40% more coins.\",\"upgrades.sturdy_bricks.help\":\"Bricks sometimes resist hits but drop more coins.\",\"upgrades.sturdy_bricks.help_plural\":\"Bricks resist more and drop more coins\",\"upgrades.sturdy_bricks.name\":\"Sturdy bricks\",\"upgrades.telekinesis.fullHelp\":\"Right after the ball hits your puck, you'll be able to direct it left and right by moving your puck. \\n\\n\\nThe effect stops when the ball hits a brick and resets the next time it touches the puck. It also does nothing when the ball is going downward after bouncing at the top.\",\"upgrades.telekinesis.help\":\"Puck controls the ball's trajectory\",\"upgrades.telekinesis.help_plural\":\"Stronger effect on the ball\",\"upgrades.telekinesis.name\":\"Telekinesis\",\"upgrades.top_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one. However, your combo will reset as soon as your ball hit the top of the screen. \\n\\nWhen your combo is above the minimum, a red bar will appear at the top to remind you that you should avoid hitting it. \\n\\nThe effect stacks with other combo perks.\",\"upgrades.top_is_lava.help\":\"More coins if you don't touch the top.\",\"upgrades.top_is_lava.name\":\"Icarus\",\"upgrades.viscosity.fullHelp\":\"Coins normally accelerate with gravity and explosions to pretty high speeds. \\n\\nThis perk constantly makes them slow down, as if they were in some sort of viscous liquid. \\n\\nThis makes catching them easier, and combines nicely with perks that influence the coin's movement.\",\"upgrades.viscosity.help\":\"Slower coin fall\",\"upgrades.viscosity.name\":\"Viscosity\",\"upgrades.wind.fullHelp\":\"The wind depends on where your puck is, if it's in the center of the screen nothing happens, if it's on the left it will blow left-wise, if it's on the right of the screen then it will blow right-wise. \\n\\nThe wind affects both the balls and coins.\",\"upgrades.wind.help\":\"Puck position creates wind\",\"upgrades.wind.help_plural\":\"Stronger wind force\",\"upgrades.wind.name\":\"Wind\"}"); +module.exports = JSON.parse("{\"confirmRestart.no\":\"Cancel\",\"confirmRestart.text\":\"You're about to start a new run, is that really what you wanted ?\",\"confirmRestart.title\":\"Start a new run ?\",\"confirmRestart.yes\":\"Restart game\",\"gameOver.cumulative_total\":\"Your total cumulative score went from {{startTs}} to {{endTs}}.\",\"gameOver.lost.summary\":\"You dropped the ball after catching {{score}} coins.\",\"gameOver.lost.title\":\"Game Over\",\"gameOver.next_unlock\":\"Score {{points}} more points to reach the next unlock\",\"gameOver.restart\":\"Start a new run\",\"gameOver.stats.balls_lost\":\"Balls lost\",\"gameOver.stats.bricks_broken\":\"Bricks broken\",\"gameOver.stats.bricks_per_minute\":\"Bricks broken per minute\",\"gameOver.stats.catch_rate\":\"Catch rate\",\"gameOver.stats.combo_avg\":\"Average combo\",\"gameOver.stats.combo_max\":\"Max combo\",\"gameOver.stats.duration_per_level\":\"Duration per level\",\"gameOver.stats.hit_rate\":\"Hit rate\",\"gameOver.stats.intro\":\"Find below your run statistics compared to your {{count}} best runs.\",\"gameOver.stats.level_reached\":\"Level reached\",\"gameOver.stats.total_score\":\"Total score\",\"gameOver.stats.upgrades_applied\":\"Upgrades applied\",\"gameOver.test_run\":\"This test run and its score are not being recorded\",\"gameOver.unlocked_count\":\"You unlocked {{count}} item(s) :\",\"gameOver.win.summary\":\"You cleared all levels for this run, catching {{score}} coins in total.\",\"gameOver.win.title\":\"Run finished\",\"level_up.after_buttons\":\"You just finished level {{level}}/{{max}} and picked those upgrades so far :\",\"level_up.before_buttons\":\"You caught {{score}} coins {{catchGain}} out of {{levelSpawnedCoins}} in {{time}} seconds ${timeGain}.\\n\\nYou missed {{levelMisses}} times {{missesGain}}.\\n\\n{{compliment}}\",\"level_up.compliment_advice\":\"Try to catch all coins, never miss the bricks or clear the level under 30s to gain additional choices and upgrades.\",\"level_up.compliment_good\":\"Well done !\",\"level_up.compliment_perfect\":\"Impressive, keep it up !\",\"level_up.pick_upgrade_title\":\"Pick an upgrade\",\"level_up.plus_one_choice\":\"(+1 choice)\",\"level_up.plus_one_upgrade\":\"(+1 upgrade and choice)\",\"level_up.unlocked_level\":\" (Level)\",\"level_up.unlocked_perk\":\" (Perk)\",\"level_up.upgrade_perk_to_level\":\" lvl {{level}}\",\"main_menu.basic\":\"Basic graphics\",\"main_menu.basic_help\":\"Fewer particles and flashes, better performance.\",\"main_menu.footer_html\":\"

Made in France by Renan LE CARO. \\n Privacy Policy\\n F-Droid\\n Google Play\\n itch.io \\n Gitlab\\n Web version\\n HackerNews\\n v.{{appVersion}}

\",\"main_menu.fullscreen\":\"Fullscreen\",\"main_menu.fullscreen_exit\":\"Exit Fullscreen\",\"main_menu.fullscreen_exit_help\":\"Might not work on some machines\",\"main_menu.fullscreen_help\":\"Might not work on some machines\",\"main_menu.kid\":\"Kids mode\",\"main_menu.kid_help\":\"Start future runs with \\\"slower ball\\\".\",\"main_menu.language\":\"Language\",\"main_menu.language_help\":\"Choose the game's language\",\"main_menu.mobile\":\"Mobile mode\",\"main_menu.mobile_help\":\"Leaves space for your thumb under the puck.\",\"main_menu.pointer_lock\":\"Mouse pointer lock\",\"main_menu.pointer_lock_help\":\"Locks and hides the mouse cursor.\",\"main_menu.record\":\"Record gameplay videos\",\"main_menu.record_download\":\"Download video ({{size}} MB)\",\"main_menu.record_help\":\"Get a video of each level.\",\"main_menu.reset\":\"Reset Game\",\"main_menu.reset_cancel\":\"No\",\"main_menu.reset_confirm\":\"Yes\",\"main_menu.reset_help\":\"Erase high score and statistics\",\"main_menu.reset_instruction\":\"You will loose all progress you made in the game, are you sure ?\",\"main_menu.resume\":\"Resume\",\"main_menu.resume_help\":\"Return to your run\",\"main_menu.sounds\":\"Game sounds\",\"main_menu.sounds_help\":\"Can slow down some phones.\",\"main_menu.title\":\"Breakout 71\",\"main_menu.unlocks\":\"Starting perk\",\"main_menu.unlocks_help\":\"Try perks and levels you unlocked\",\"play.close_modale_window_tooltip\":\"close \",\"play.current_lvl\":\"L{{level}}/{{max}}\",\"play.menu_label\":\"menu\",\"play.missed_ball\":\"miss\",\"play.mobile_press_to_play\":\"Press and hold here to play\",\"sandbox.help\":\"Test any perk combination\",\"sandbox.instructions\":\"Select perks below and press \\\"start run\\\" to try them out in a test run. Scores and stats are not recorded.\",\"sandbox.start\":\"Start test run\",\"sandbox.title\":\"Sandbox mode\",\"sandbox.unlocks_at\":\"Unlocks at total score ${{score}}\",\"score_panel.restart\":\"Restart\",\"score_panel.restart_help\":\"Start a brand new run\",\"score_panel.resume\":\"Resume\",\"score_panel.resume_help\":\"Return to your run\",\"score_panel.test_run\":\"This is a test run, score is not recorded permanently\",\"score_panel.title\":\"{{score}} points at level {{level}}/{{max}} \",\"score_panel.upgrades_picked\":\"Upgrades picked so far : \",\"unlocks.greyed_out_help\":\"The greyed out ones can be unlocked by increasing your total score. The total score increases every time you score in game.\",\"unlocks.intro\":\"Your total score is {{ts}}. Below are all the upgrades and levels the games has to offer.\",\"unlocks.level_description\":\"A {{size}}x{{size}} level with {{bricks}} bricks\",\"unlocks.title\":\"You unlocked {{percentUnlock}}% of the game.\",\"unlocks.unlocks_at\":\"Unlocks at total score {{threshold}}.\",\"upgrades.ball_attract_ball.fullHelp\":\"Balls that are more than half a screen width away will start attracting each other. \\n\\nThe attraction force is stronger when they are furthest away from each other.\\n\\nRainbow particles will fly to symbolize the attraction force. This perk is only offered if you have more than one ball already.\",\"upgrades.ball_attract_ball.help\":\"Balls attract balls\",\"upgrades.ball_attract_ball.help_plural\":\"Stronger attraction force\",\"upgrades.ball_attract_ball.name\":\"Gravity\",\"upgrades.ball_repulse_ball.fullHelp\":\"Balls that are less than half a screen width away will start repulsing each other. The repulsion force is stronger if they are close to each other. Particles will jet out to symbolize this force being applied. This perk is only offered if you have more than one ball already.\",\"upgrades.ball_repulse_ball.help\":\"Balls repulse balls\",\"upgrades.ball_repulse_ball.help_plural\":\"Stronger repulsion force\",\"upgrades.ball_repulse_ball.name\":\"Personal space\",\"upgrades.base_combo.fullHelp\":\"Your combo (number of coins per bricks) normally starts at 1 at the beginning of the level, and resets to one when you bounce around without hitting anything. With this perk, the combo starts 3 points higher, so you'll always get at least 4 coins per brick. Whenever your combo reset, it will go back to 4 and not 1. Your ball will glitter a bit to indicate that its combo is higher than one.\",\"upgrades.base_combo.help\":\"Combo starts at {{coins}}.\",\"upgrades.base_combo.name\":\"+3 base combo\",\"upgrades.bigger_explosions.fullHelp\":\"The default explosion clears a 3x3 square, with this it becomes a 5x5 square, and the blow on the coins is also significantly stronger. The screen will flash after each explosion (except in basic mode)\",\"upgrades.bigger_explosions.help\":\"Bigger explosions\",\"upgrades.bigger_explosions.name\":\"Kaboom\",\"upgrades.bigger_puck.fullHelp\":\"A bigger puck makes it easier to never miss the ball and to catch more coins, and also to precisely angle the bounces (the ball's angle only depends on where it hits the puck). \\n However, a large puck is harder to use around the sides of the level, and will make it sometimes unavoidable to miss (not hit anything) which comes with downsides. \",\"upgrades.bigger_puck.help\":\"Easily catch more coins.\",\"upgrades.bigger_puck.name\":\"Bigger puck\",\"upgrades.coin_magnet.fullHelp\":\"Directs the coins to the puck. The effect is stronger if the coin is close to it already. Catching 90% or 100% of coins bring special bonuses in the game. \\n\\nAnother way to catch more coins is to hit bricks from the bottom. The ball's speed and direction impacts the spawned coin's velocity. \",\"upgrades.coin_magnet.help\":\"Puck attracts coins\",\"upgrades.coin_magnet.help_plural\":\"Stronger effect on the coins\",\"upgrades.coin_magnet.name\":\"Coins magnet\",\"upgrades.compound_interest.fullHelp\":\"Your combo will grow by one every time you break a brick, spawning more and more coin with every brick you break. \\n\\nBe sure however to catch every one of those coins with your puck, as any lost coin will reset your combo. \\n\\nOnce your combo is above the minimum, the bottom of the play area will have a red line to remind you that coins should not go there.\\n\\nThis perk combines with other combo perks, the combo will rise faster but reset more easily.\",\"upgrades.compound_interest.help\":\"+1 combo per brick broken, resets on coin lost\",\"upgrades.compound_interest.name\":\"Compound interest\",\"upgrades.extra_levels.fullHelp\":\"The default run can last a max of 7 levels, after which the game is over and whatever score you reached is your run score. \\n\\nEach level of this perk lets you go one level higher. The last levels are often the ones where you make the most score, so the difference can be dramatic.\",\"upgrades.extra_levels.help\":\"Play {{count}} levels instead of 7\",\"upgrades.extra_levels.name\":\"+1 level\",\"upgrades.extra_life.fullHelp\":\"Normally, you have one ball per run, and the run is over as soon as you drop it.\\n\\nThis perk adds a white bar at the bottom of the screen that will save a ball once, and break in the process. \\n\\nYou'll loose one level of that perk every time a ball bounces at the bottom of the screen.\",\"upgrades.extra_life.help\":\"The ball will bounce once on the bottom line before being lost.\",\"upgrades.extra_life.help_plural\":\"The ball will bounce on the bottom {{lvl}} times before being lost.\",\"upgrades.extra_life.name\":\"+1 life\",\"upgrades.hot_start.fullHelp\":\"At the start of every level, your combo will start at +15 points, but then every second it will be decreased by one.\\n\\nThis means the first 15 seconds in a level will spawn many more coins than the following ones, and you should make sure that you clear the level quickly. \\n\\nThe effect stacks with other combo related perks, so you might be able to raise the combo after the 15s timeout, but it will keep ticking down. \\n\\nEvery time you take the perk again, the effect will be more dramatic.\",\"upgrades.hot_start.help\":\"Start at combo {{start}}, -{{lvl}} combo per second\",\"upgrades.hot_start.name\":\"Hot start\",\"upgrades.instant_upgrade.fullHelp\":\"Immediately pick two upgrades, so that you get one free one and one to repay the one used to get this perk. Every further menu to pick upgrades will have fewer options to choose from.\",\"upgrades.instant_upgrade.help\":\"-1 choice until run end.\",\"upgrades.instant_upgrade.name\":\"+2 upgrades now\",\"upgrades.left_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\\n\\nHowever, your combo will reset as soon as your ball hits the left side . \\n\\nAs soon as your combo rises, the left side becomes red to remind you that you should avoid hitting them. \\n\\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any of the reset conditions are met.\",\"upgrades.left_is_lava.help\":\"+1 combo per brick broken, resets on left side hit\",\"upgrades.left_is_lava.name\":\"Avoid left side\",\"upgrades.metamorphosis.fullHelp\":\"With this perk, coins will be of the color of the brick they come from, and will color the first brick they touch in the same color. Coins spawn with the speed of the ball that broke them, which means you can aim a bit in the direction of the bricks you want to \\\"paint\\\".\",\"upgrades.metamorphosis.help\":\"Coins stain the bricks they touch\",\"upgrades.metamorphosis.name\":\"Metamorphosis\",\"upgrades.multiball.fullHelp\":\"As soon as you drop the ball in Breakout 71, you loose. \\n\\nWith this perk, you get two balls, and so you can afford to lose one. \\n\\nThe lost balls come back on the next level. \\n\\nHaving more than one balls makes some further perks available, and of course clears the level faster.\",\"upgrades.multiball.help\":\"Start every levels with {{count}} balls.\",\"upgrades.multiball.name\":\"+1 ball\",\"upgrades.one_more_choice.fullHelp\":\"Every upgrade menu will have one more option. Doesn't increase the number of upgrades you can pick.\",\"upgrades.one_more_choice.help\":\"Further level ups will offer one more option in the list\",\"upgrades.one_more_choice.name\":\"+1 choice until run end\",\"upgrades.picky_eater.fullHelp\":\"Whenever you break a brick the same color as your ball, your combo increases by one. \\nIf it's a different color, the ball takes that new color, but the combo resets.\\nThe bricks with the right color will get a white border. \\nOnce you get a combo higher than your minimum, the bricks of the wrong color will get a red halo. \\nIf you have more than one ball, they all change color whenever one of them hits a brick.\",\"upgrades.picky_eater.help\":\"+1 combo per brick broken, resets on ball color change\",\"upgrades.picky_eater.name\":\"Picky eater\",\"upgrades.pierce.fullHelp\":\"The ball normally bounces as soon as it touches something. With this perk, it will continue its trajectory for up to 3 bricks broken. \\nAfter that, it will bounce on the 4th brick, and you'll need to touch the puck to reset the counter.\",\"upgrades.pierce.help\":\"Ball pierces {{count}} bricks after a puck bounce\",\"upgrades.pierce.name\":\"Piercing\",\"upgrades.pierce_color.fullHelp\":\"Whenever a ball hits a brick of the same color, it will just go through unimpeded. \\nOnce it reaches a brick of a different color, it will break it, take its color and bounce.\",\"upgrades.pierce_color.help\":\"Balls pierce bricks of their color\",\"upgrades.pierce_color.name\":\"Color pierce\",\"upgrades.puck_repulse_ball.fullHelp\":\"When a ball gets close to the puck, it will start slowing down, and even potentially bouncing without touching the puck.\",\"upgrades.puck_repulse_ball.help\":\"Puck repulses balls\",\"upgrades.puck_repulse_ball.help_plural\":\"Stronger repulsion force\",\"upgrades.puck_repulse_ball.name\":\"Soft landing\",\"upgrades.respawn.fullHelp\":\"After breaking two or more bricks, when the ball hits the puck, the first brick will be put back in place, provided that space is free and the brick wasn't a bomb.\\n\\nSome particle effect will let you know where bricks will appear. Leveling this up lets you re-spawn up to 4 bricks at a time, but there should always be at least one destroyed.\",\"upgrades.respawn.help\":\"The first brick hit of two+ will re-spawn\",\"upgrades.respawn.help_plural\":\"More bricks can re-spawn\",\"upgrades.respawn.name\":\"Re-spawn\",\"upgrades.right_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\\n\\nHowever, your combo will reset as soon as your ball hits the right side . \\n\\nAs soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them\\n\\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any\\nof the reset conditions are met.\",\"upgrades.right_is_lava.help\":\"+1 combo per brick broken, resets on right side hit\",\"upgrades.right_is_lava.name\":\"Avoid right side\",\"upgrades.sapper.fullHelp\":\"Instead of just disappearing, the first brick you break will be replaced by a bomb brick. \\n\\nBouncing the ball on the puck re-arms the effect. \\n\\nLeveling-up this perk will allow you to place more bombs.\\n\\nRemember that bombs impact the velocity of nearby coins, so too many explosions could make it hard to catch the fruits of your hard work.\",\"upgrades.sapper.help\":\"The first brick broken becomes a bomb.\",\"upgrades.sapper.help_plural\":\"The first {{lvl}} bricks broken become bombs.\",\"upgrades.sapper.name\":\"Sapper\",\"upgrades.skip_last.fullHelp\":\"You need to break all bricks to go to the next level. However, it can be hard to get the last ones. \\n\\nClearing a level early brings extra choices when upgrading. Never missing the bricks is also very beneficial. \\n\\nSo if you find it difficult to break the last bricks, getting this perk a few time can help.\",\"upgrades.skip_last.help\":\"The last brick will explode.\",\"upgrades.skip_last.help_plural\":\"The last {{lvl}} bricks will explode.\",\"upgrades.skip_last.name\":\"Easy Cleanup\",\"upgrades.slow_down.fullHelp\":\"The ball starts relatively slow, but every level of your run it will start a bit faster. \\n\\nIt will also accelerate if you spend a lot of time in one level. \\n\\nThis perk makes it more manageable. \\n\\nYou can get it at the start every time by enabling kid mode in the menu.\",\"upgrades.slow_down.help\":\"Ball moves more slowly\",\"upgrades.slow_down.name\":\"Slower ball\",\"upgrades.smaller_puck.fullHelp\":\"This makes the puck smaller, which in theory makes some corner shots easier, but really just raises the difficulty.\\n\\nThat's why you also get a nice bonus of +5 coins per brick for all bricks you'll break after picking this. \",\"upgrades.smaller_puck.help\":\"Also gives +5 base combo\",\"upgrades.smaller_puck.help_plural\":\"Even smaller puck and higher base combo\",\"upgrades.smaller_puck.name\":\"Smaller puck\",\"upgrades.soft_reset.fullHelp\":\"Limit the impact of a combo reset.\",\"upgrades.soft_reset.help\":\"Combo resets keeps {{percent}}%\",\"upgrades.soft_reset.name\":\"Soft reset\",\"upgrades.streak_shots.fullHelp\":\"Every time you break a brick, your combo (number of coins per bricks) increases by one. \\n\\nHowever, as soon as the ball touches your puck, the combo is reset to its default value, and you'll just get one coin per brick.\\n\\nOnce your combo rises above the base value, your puck will become red to remind you that it will destroy your combo to touch it with the ball.\\n\\nThis can stack with other combo related perks, the combo will rise faster but reset more easily as any of the conditions is enough to reset it. \",\"upgrades.streak_shots.help\":\"More coins if you break many bricks at once.\",\"upgrades.streak_shots.name\":\"Single puck hit streak\",\"upgrades.sturdy_bricks.fullHelp\":\"With level one of this perk, the ball has a 20% chance to bounce harmlessly on bricks, \\n but generates 10% more coins when it does break one. \\n This +10% is not shown in the combo number. At level 4 the ball has 80% chance of bouncing and brings 40% more coins.\",\"upgrades.sturdy_bricks.help\":\"Bricks sometimes resist hits but drop more coins.\",\"upgrades.sturdy_bricks.help_plural\":\"Bricks resist more and drop more coins\",\"upgrades.sturdy_bricks.name\":\"Sturdy bricks\",\"upgrades.telekinesis.fullHelp\":\"Right after the ball hits your puck, you'll be able to direct it left and right by moving your puck. \\n\\n\\nThe effect stops when the ball hits a brick and resets the next time it touches the puck. It also does nothing when the ball is going downward after bouncing at the top.\",\"upgrades.telekinesis.help\":\"Puck controls the ball's trajectory\",\"upgrades.telekinesis.help_plural\":\"Stronger effect on the ball\",\"upgrades.telekinesis.name\":\"Telekinesis\",\"upgrades.top_is_lava.fullHelp\":\"Whenever you break a brick, your combo will increase by one. However, your combo will reset as soon as your ball hit the top of the screen. \\n\\nWhen your combo is above the minimum, a red bar will appear at the top to remind you that you should avoid hitting it. \\n\\nThe effect stacks with other combo perks.\",\"upgrades.top_is_lava.help\":\"More coins if you don't touch the top.\",\"upgrades.top_is_lava.name\":\"Icarus\",\"upgrades.viscosity.fullHelp\":\"Coins normally accelerate with gravity and explosions to pretty high speeds. \\n\\nThis perk constantly makes them slow down, as if they were in some sort of viscous liquid. \\n\\nThis makes catching them easier, and combines nicely with perks that influence the coin's movement.\",\"upgrades.viscosity.help\":\"Slower coin fall\",\"upgrades.viscosity.name\":\"Viscosity\",\"upgrades.wind.fullHelp\":\"The wind depends on where your puck is, if it's in the center of the screen nothing happens, if it's on the left it will blow left-wise, if it's on the right of the screen then it will blow right-wise. \\n\\nThe wind affects both the balls and coins.\",\"upgrades.wind.help\":\"Puck position creates wind\",\"upgrades.wind.help_plural\":\"Stronger wind force\",\"upgrades.wind.name\":\"Wind\"}"); },{}],"5blfu":[function(require,module,exports,__globalThis) { // Settings @@ -3190,10 +1644,12 @@ var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); parcelHelpers.defineInteropFlag(exports); parcelHelpers.export(exports, "getSettingValue", ()=>getSettingValue); parcelHelpers.export(exports, "setSettingValue", ()=>setSettingValue); +parcelHelpers.export(exports, "getTotalScore", ()=>getTotalScore); +parcelHelpers.export(exports, "addToTotalScore", ()=>addToTotalScore); let cachedSettings = {}; function getSettingValue(key, defaultValue) { if (typeof cachedSettings[key] == "undefined") try { - const ls = localStorage.getItem("breakout-settings-enable-" + key); + const ls = localStorage.getItem(key); if (ls) cachedSettings[key] = JSON.parse(ls); } catch (e) { console.warn(e); @@ -3203,79 +1659,20 @@ function getSettingValue(key, defaultValue) { function setSettingValue(key, value) { cachedSettings[key] = value; try { - localStorage.setItem("breakout-settings-enable-" + key, JSON.stringify(value)); + localStorage.setItem(key, JSON.stringify(value)); } catch (e) { console.warn(e); } } - -},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"d5NoS":[function(require,module,exports,__globalThis) { -var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); -parcelHelpers.defineInteropFlag(exports); -parcelHelpers.export(exports, "options", ()=>options); -parcelHelpers.export(exports, "isOptionOn", ()=>isOptionOn); -parcelHelpers.export(exports, "toggleOption", ()=>toggleOption); -var _game = require("./game"); -var _i18N = require("./i18n/i18n"); -var _settings = require("./settings"); -const options = { - sound: { - default: true, - name: (0, _i18N.t)('main_menu.sounds'), - help: (0, _i18N.t)('main_menu.sounds_help'), - afterChange: ()=>{}, - disabled: ()=>false - }, - "mobile-mode": { - default: window.innerHeight > window.innerWidth, - name: (0, _i18N.t)('main_menu.mobile'), - help: (0, _i18N.t)('main_menu.mobile_help'), - afterChange () { - (0, _game.fitSize)(); - }, - disabled: ()=>false - }, - basic: { - default: false, - name: (0, _i18N.t)('main_menu.basic'), - help: (0, _i18N.t)('main_menu.basic_help'), - afterChange: ()=>{}, - disabled: ()=>false - }, - pointerLock: { - default: false, - name: (0, _i18N.t)('main_menu.pointer_lock'), - help: (0, _i18N.t)('main_menu.pointer_lock_help'), - afterChange: ()=>{}, - disabled: ()=>!document.body.requestPointerLock - }, - easy: { - default: false, - name: (0, _i18N.t)('main_menu.kid'), - help: (0, _i18N.t)('main_menu.kid_help'), - afterChange: ()=>{}, - disabled: ()=>false - }, - // Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app - record: { - default: false, - name: (0, _i18N.t)('main_menu.record'), - help: (0, _i18N.t)('main_menu.record_help'), - afterChange: ()=>{}, - disabled () { - return window.location.search.includes("isInWebView=true"); - } - } -}; -function isOptionOn(key) { - return (0, _settings.getSettingValue)(key, options[key]?.default); +function getTotalScore() { + return getSettingValue('breakout_71_total_score', 0); } -function toggleOption(key) { - (0, _settings.setSettingValue)(key, !isOptionOn(key)); - options[key].afterChange(); +function addToTotalScore(gameState, points) { + if (gameState.isCreativeModeRun) return; + setSettingValue('breakout_71_total_score', getTotalScore() + points); } -},{"./game":"edeGs","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./i18n/i18n":"eNPRm","./settings":"5blfu"}],"dQKPV":[function(require,module,exports,__globalThis) { +},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"dQKPV":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); parcelHelpers.defineInteropFlag(exports); parcelHelpers.export(exports, "sounds", ()=>sounds); @@ -3457,12 +1854,180 @@ function createOscillator(context, frequency, type) { return oscillator; } -},{"./game":"edeGs","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./options":"d5NoS"}],"gVgfx":[function(require,module,exports,__globalThis) { +},{"./game":"edeGs","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./options":"d5NoS"}],"d5NoS":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "options", ()=>options); +parcelHelpers.export(exports, "isOptionOn", ()=>isOptionOn); +parcelHelpers.export(exports, "toggleOption", ()=>toggleOption); +var _i18N = require("./i18n/i18n"); +var _settings = require("./settings"); +const options = { + sound: { + default: true, + name: (0, _i18N.t)('main_menu.sounds'), + help: (0, _i18N.t)('main_menu.sounds_help'), + disabled: ()=>false + }, + "mobile-mode": { + default: window.innerHeight > window.innerWidth, + name: (0, _i18N.t)('main_menu.mobile'), + help: (0, _i18N.t)('main_menu.mobile_help'), + disabled: ()=>false + }, + basic: { + default: false, + name: (0, _i18N.t)('main_menu.basic'), + help: (0, _i18N.t)('main_menu.basic_help'), + disabled: ()=>false + }, + pointerLock: { + default: false, + name: (0, _i18N.t)('main_menu.pointer_lock'), + help: (0, _i18N.t)('main_menu.pointer_lock_help'), + disabled: ()=>!document.body.requestPointerLock + }, + easy: { + default: false, + name: (0, _i18N.t)('main_menu.kid'), + help: (0, _i18N.t)('main_menu.kid_help'), + disabled: ()=>false + }, + // Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app + record: { + default: false, + name: (0, _i18N.t)('main_menu.record'), + help: (0, _i18N.t)('main_menu.record_help'), + disabled () { + return window.location.search.includes("isInWebView=true"); + } + } +}; +function isOptionOn(key) { + return (0, _settings.getSettingValue)("breakout-settings-enable-" + key, options[key]?.default); +} +function toggleOption(key) { + (0, _settings.setSettingValue)("breakout-settings-enable-" + key, !isOptionOn(key)); +} + +},{"./i18n/i18n":"eNPRm","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./settings":"5blfu"}],"cEeac":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "getMajorityValue", ()=>getMajorityValue); +parcelHelpers.export(exports, "sample", ()=>sample); +parcelHelpers.export(exports, "sumOfKeys", ()=>sumOfKeys); +parcelHelpers.export(exports, "makeEmptyPerksMap", ()=>makeEmptyPerksMap); +parcelHelpers.export(exports, "brickCenterX", ()=>brickCenterX); +parcelHelpers.export(exports, "brickCenterY", ()=>brickCenterY); +parcelHelpers.export(exports, "getRowColIndex", ()=>getRowColIndex); +parcelHelpers.export(exports, "getPossibleUpgrades", ()=>getPossibleUpgrades); +parcelHelpers.export(exports, "max_levels", ()=>max_levels); +parcelHelpers.export(exports, "pickedUpgradesHTMl", ()=>pickedUpgradesHTMl); +parcelHelpers.export(exports, "currentLevelInfo", ()=>currentLevelInfo); +parcelHelpers.export(exports, "isTelekinesisActive", ()=>isTelekinesisActive); +parcelHelpers.export(exports, "findLast", ()=>findLast); +parcelHelpers.export(exports, "distance2", ()=>distance2); +parcelHelpers.export(exports, "distanceBetween", ()=>distanceBetween); +var _loadGameData = require("./loadGameData"); +function getMajorityValue(arr) { + const count = {}; + arr.forEach((v)=>count[v] = (count[v] || 0) + 1); + // Object.values inline polyfill + const max = Math.max(...Object.keys(count).map((k)=>count[k])); + return sample(Object.keys(count).filter((k)=>count[k] == max)); +} +function sample(arr) { + return arr[Math.floor(arr.length * Math.random())]; +} +function sumOfKeys(obj) { + if (!obj) return 0; + return Object.values(obj)?.reduce((a, b)=>a + b, 0) || 0; +} +const makeEmptyPerksMap = (upgrades)=>{ + const p = {}; + upgrades.forEach((u)=>p[u.id] = 0); + return p; +}; +function brickCenterX(gameState, index) { + return gameState.offsetX + (index % gameState.gridSize + 0.5) * gameState.brickWidth; +} +function brickCenterY(gameState, index) { + return (Math.floor(index / gameState.gridSize) + 0.5) * gameState.brickWidth; +} +function getRowColIndex(gameState, row, col) { + if (row < 0 || col < 0 || row >= gameState.gridSize || col >= gameState.gridSize) return -1; + return row * gameState.gridSize + col; +} +function getPossibleUpgrades(gameState) { + return (0, _loadGameData.upgrades).filter((u)=>gameState.totalScoreAtRunStart >= u.threshold).filter((u)=>!u?.requires || gameState.perks[u?.requires]); +} +function max_levels(gameState) { + return 7 + gameState.perks.extra_levels; +} +function pickedUpgradesHTMl(gameState) { + let list = ""; + for (let u of (0, _loadGameData.upgrades))for(let i = 0; i < gameState.perks[u.id]; i++)list += (0, _loadGameData.icons)["icon:" + u.id] + " "; + return list; +} +function currentLevelInfo(gameState) { + return gameState.runLevels[gameState.currentLevel % gameState.runLevels.length]; +} +function isTelekinesisActive(gameState, ball) { + return gameState.perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; +} +function findLast(arr, predicate) { + let i = arr.length; + while(--i)if (predicate(arr[i], i, arr)) return arr[i]; +} +function distance2(a, b) { + return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); +} +function distanceBetween(a, b) { + return Math.sqrt(distance2(a, b)); +} + +},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./loadGameData":"l1B4x"}],"kRstf":[function(require,module,exports,__globalThis) { +if ("serviceWorker" in navigator && window.location.search.includes("isPWA=true")) // @ts-ignore +navigator.serviceWorker.register(require("994b1835e761d5ae")); + +},{"994b1835e761d5ae":"6vb6r"}],"6vb6r":[function(require,module,exports,__globalThis) { +module.exports = require("e4f4efefa01a2f07").getBundleURL('28aWT') + "sw-b71.js"; + +},{"e4f4efefa01a2f07":"lgJ39"}],"9ZeQl":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "setMousePos", ()=>setMousePos); parcelHelpers.export(exports, "resetBalls", ()=>resetBalls); parcelHelpers.export(exports, "putBallsAtPuck", ()=>putBallsAtPuck); +parcelHelpers.export(exports, "normalizeGameState", ()=>normalizeGameState); +parcelHelpers.export(exports, "baseCombo", ()=>baseCombo); +parcelHelpers.export(exports, "resetCombo", ()=>resetCombo); +parcelHelpers.export(exports, "decreaseCombo", ()=>decreaseCombo); +parcelHelpers.export(exports, "spawnExplosion", ()=>spawnExplosion); +parcelHelpers.export(exports, "explodeBrick", ()=>explodeBrick); +parcelHelpers.export(exports, "dontOfferTooSoon", ()=>dontOfferTooSoon); +parcelHelpers.export(exports, "pickRandomUpgrades", ()=>pickRandomUpgrades); +parcelHelpers.export(exports, "addToScore", ()=>addToScore); +parcelHelpers.export(exports, "setLevel", ()=>setLevel); +parcelHelpers.export(exports, "rainbowColor", ()=>rainbowColor); +parcelHelpers.export(exports, "repulse", ()=>repulse); +parcelHelpers.export(exports, "attract", ()=>attract); +parcelHelpers.export(exports, "gameStateTick", ()=>gameStateTick); +parcelHelpers.export(exports, "ballTick", ()=>ballTick); +var _sounds = require("./sounds"); var _gameUtils = require("./game_utils"); +var _i18N = require("./i18n/i18n"); +var _loadGameData = require("./loadGameData"); +var _settings = require("./settings"); +var _render = require("./render"); +var _gameOver = require("./gameOver"); +var _game = require("./game"); +var _recording = require("./recording"); +var _options = require("./options"); +function setMousePos(gameState, x) { + // Sets the puck position, and updates the ball position if they are supposed to follow it + gameState.puckPosition = x; +} function resetBalls(gameState) { const count = 1 + (gameState.perks?.multiball || 0); const perBall = gameState.puckWidth / (count + 1); @@ -3513,41 +2078,14 @@ function putBallsAtPuck(gameState) { ball.piercedSinceBounce = 0; }); } - -},{"./game_utils":"cEeac","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"cEeac":[function(require,module,exports,__globalThis) { -var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); -parcelHelpers.defineInteropFlag(exports); -parcelHelpers.export(exports, "getMajorityValue", ()=>getMajorityValue); -parcelHelpers.export(exports, "sample", ()=>sample); -parcelHelpers.export(exports, "sumOfKeys", ()=>sumOfKeys); -parcelHelpers.export(exports, "makeEmptyPerksMap", ()=>makeEmptyPerksMap); -function getMajorityValue(arr) { - const count = {}; - arr.forEach((v)=>count[v] = (count[v] || 0) + 1); - // Object.values inline polyfill - const max = Math.max(...Object.keys(count).map((k)=>count[k])); - return sample(Object.keys(count).filter((k)=>count[k] == max)); +function normalizeGameState(gameState) { + // 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 / 30000 - gameState.perks.slow_down * 2); + gameState.puckWidth = gameState.gameZoneWidth / 12 * (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); + if (gameState.puckPosition < gameState.offsetXRoundedDown + gameState.puckWidth / 2) gameState.puckPosition = gameState.offsetXRoundedDown + gameState.puckWidth / 2; + if (gameState.puckPosition > gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp - gameState.puckWidth / 2) gameState.puckPosition = gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp - gameState.puckWidth / 2; + if (!gameState.running && !gameState.levelTime) putBallsAtPuck(gameState); } -function sample(arr) { - return arr[Math.floor(arr.length * Math.random())]; -} -function sumOfKeys(obj) { - if (!obj) return 0; - return Object.values(obj)?.reduce((a, b)=>a + b, 0) || 0; -} -const makeEmptyPerksMap = (upgrades)=>{ - const p = {}; - upgrades.forEach((u)=>p[u.id] = 0); - return p; -}; - -},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"9S1mS":[function(require,module,exports,__globalThis) { -var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); -parcelHelpers.defineInteropFlag(exports); -parcelHelpers.export(exports, "baseCombo", ()=>baseCombo); -parcelHelpers.export(exports, "resetCombo", ()=>resetCombo); -parcelHelpers.export(exports, "decreaseCombo", ()=>decreaseCombo); -var _sounds = require("./sounds"); function baseCombo(gameState) { return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; } @@ -3555,7 +2093,7 @@ function resetCombo(gameState, x, y) { const prev = gameState.combo; gameState.combo = baseCombo(gameState); if (!gameState.levelTime) gameState.combo += gameState.perks.hot_start * 15; - if (prev > gameState.combo && gameState.perks.soft_reset) gameState.combo += Math.floor((prev - gameState.combo) / (1 + gameState.perks.soft_reset)); + if (prev > gameState.combo && gameState.perks.soft_reset) gameState.combo += Math.floor((prev - gameState.combo) * (gameState.perks.soft_reset * 10) / 100); const lost = Math.max(0, prev - gameState.combo); if (lost) { for(let i = 0; i < lost && i < 8; i++)setTimeout(()=>(0, _sounds.sounds).comboDecrease(), i * 100); @@ -3590,15 +2128,1517 @@ function decreaseCombo(gameState, by, x, y) { }); } } +function spawnExplosion(gameState, count, x, y, color, duration = 150, size = gameState.coinSize) { + if (!!(0, _options.isOptionOn)("basic")) return; + if (gameState.flashes.length > gameState.MAX_PARTICLES) // Avoid freezing when lots of explosion happen at once + count = 1; + for(let i = 0; i < count; i++)gameState.flashes.push({ + type: "particle", + time: gameState.levelTime, + size, + x: x + (Math.random() - 0.5) * gameState.brickWidth / 2, + y: y + (Math.random() - 0.5) * gameState.brickWidth / 2, + vx: (Math.random() - 0.5) * 30, + vy: (Math.random() - 0.5) * 30, + color, + duration, + ethereal: false + }); +} +function explodeBrick(gameState, index, ball, isExplosion) { + const color = gameState.bricks[index]; + if (!color) return; + if (color === "black") { + delete gameState.bricks[index]; + const x = (0, _gameUtils.brickCenterX)(gameState, index), y = (0, _gameUtils.brickCenterY)(gameState, index); + (0, _sounds.sounds).explode(ball.x); + const col = index % gameState.gridSize; + const row = Math.floor(index / gameState.gridSize); + const size = 1 + gameState.perks.bigger_explosions; + // Break bricks around + for(let dx = -size; dx <= size; dx++)for(let dy = -size; dy <= size; dy++){ + const i = (0, _gameUtils.getRowColIndex)(gameState, row + dy, col + dx); + if (gameState.bricks[i] && i !== -1) { + // Study bricks resist explosions too + if (gameState.bricks[i] !== "black" && gameState.perks.sturdy_bricks > Math.random() * 5) continue; + explodeBrick(gameState, i, ball, true); + } + } + // Blow nearby coins + gameState.coins.forEach((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; + c.vy += dy / d2 * 10 * size / c.weight; + }); + gameState.lastExplosion = Date.now(); + gameState.flashes.push({ + type: "ball", + duration: 150, + time: gameState.levelTime, + size: gameState.brickWidth * 2, + color: "white", + x, + y + }); + spawnExplosion(gameState, 7 * (1 + gameState.perks.bigger_explosions), x, y, "white", 150, gameState.coinSize); + ball.hitSinceBounce++; + gameState.runStatistics.bricks_broken++; + } else if (color) { + // Even if it bounces we don't want to count that as a miss + ball.hitSinceBounce++; + // Flashing is take care of by the tick loop + const x = (0, _gameUtils.brickCenterX)(gameState, index), y = (0, _gameUtils.brickCenterY)(gameState, index); + gameState.bricks[index] = ""; + // coins = coins.filter((c) => !c.destroyed); + 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 = gameState.MAX_COINS * ((0, _options.isOptionOn)("basic") ? 0.5 : 1); + const spawnableCoins = gameState.coins.length > gameState.MAX_COINS ? 1 : Math.floor(maxCoins - gameState.coins.length) / 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); + gameState.coins.push({ + points, + size: gameState.coinSize, + color: gameState.perks.metamorphosis ? color : "gold", + x: cx, + y: cy, + previousX: cx, + previousY: cy, + // Use previous speed because the ball has already bounced + vx: ball.previousVX * (0.5 + Math.random()), + vy: ball.previousVY * (0.5 + Math.random()), + sx: 0, + sy: 0, + a: Math.random() * Math.PI * 2, + sa: Math.random() - 0.5, + weight: 0.8 + Math.random() * 0.2 + }); + } + gameState.combo += Math.max(0, 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); + 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); + (0, _sounds.sounds).colorChange(ball.x, 0.8); + gameState.lastExplosion = gameState.levelTime; + gameState.ballsColor = color; + if (!(0, _options.isOptionOn)("basic")) gameState.balls.forEach((ball)=>{ + spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color, 150, 15); + }); + } else (0, _sounds.sounds).comboIncreaseMaybe(gameState.combo, ball.x, 1); + } + gameState.flashes.push({ + type: "ball", + duration: 40, + time: gameState.levelTime, + size: gameState.brickWidth, + color: color, + x, + y + }); + spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color, 150, gameState.coinSize / 2); + } + if (!gameState.bricks[index] && color !== "black") ball.hitItem?.push({ + index, + color + }); +} +function dontOfferTooSoon(gameState, id) { + gameState.lastOffered[id] = Math.round(Date.now() / 1000); +} +function pickRandomUpgrades(gameState, count) { + let list = (0, _gameUtils.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).slice(0, count).sort((a, b)=>a.id > b.id ? 1 : -1); + list.forEach((u)=>{ + dontOfferTooSoon(gameState, u.id); + }); + return list.map((u)=>({ + text: u.name + (gameState.perks[u.id] ? (0, _i18N.t)('level_up.upgrade_perk_to_level', { + level: gameState.perks[u.id] + 1 + }) : ""), + icon: (0, _loadGameData.icons)["icon:" + u.id], + value: u.id, + help: u.help(gameState.perks[u.id] + 1) + })); +} +function addToScore(gameState, coin) { + coin.destroyed = true; + gameState.score += coin.points; + gameState.lastScoreIncrease = gameState.levelTime; + (0, _settings.addToTotalScore)(gameState, coin.points); + if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { + gameState.highScore = gameState.score; + localStorage.setItem("breakout-3-hs", gameState.score.toString()); + } + if (!(0, _options.isOptionOn)("basic")) gameState.flashes.push({ + type: "particle", + duration: 100 + Math.random() * 50, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: coin.color, + x: coin.previousX, + y: coin.previousY, + vx: (gameState.canvasWidth - coin.x) / 100, + vy: -coin.y / 100, + ethereal: true + }); + if (Date.now() - gameState.lastPlayedCoinGrab > 16) { + gameState.lastPlayedCoinGrab = Date.now(); + (0, _sounds.sounds).coinCatch(coin.x); + } + gameState.runStatistics.score += coin.points; +} +function setLevel(gameState, l) { + (0, _recording.stopRecording)(); + (0, _game.pause)(false); + if (l > 0) (0, _game.openUpgradesPicker)(gameState); + gameState.currentLevel = l; + gameState.levelTime = 0; + gameState.autoCleanUses = 0; + gameState.lastTickDown = gameState.levelTime; + gameState.levelStartScore = gameState.score; + gameState.levelSpawnedCoins = 0; + gameState.levelMisses = 0; + gameState.runStatistics.levelsPlayed++; + resetCombo(gameState, undefined, undefined); + resetBalls(gameState); + const lvl = (0, _gameUtils.currentLevelInfo)(gameState); + if (lvl.size !== gameState.gridSize) { + gameState.gridSize = lvl.size; + (0, _game.fitSize)(); + } + gameState.coins = []; + gameState.bricks = [ + ...lvl.bricks + ]; + gameState.flashes = []; + // 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) + (0, _render.background).src = "data:image/svg+xml;UTF8," + lvl.svg; +} +function rainbowColor() { + return `hsl(${Math.round((0, _game.gameState).levelTime / 4) * 2 % 360},100%,70%)`; +} +function repulse(gameState, a, b, power, impactsBToo) { + const distance = (0, _gameUtils.distanceBetween)(a, b); + // Ensure we don't get soft locked + const max = gameState.gameZoneWidth / 2; + 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; + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand + }); + if (impactsBToo && typeof b.vx !== "undefined" && typeof b.vy !== "undefined") gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + b.vy + (Math.random() - 0.5) * rand + }); +} +function attract(gameState, a, b, power) { + const distance = (0, _gameUtils.distanceBetween)(a, b); + // Ensure we don't get soft locked + const min = gameState.gameZoneWidth * 0.5; + 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 speed = 10; + const rand = 2; + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + a.vy + (Math.random() - 0.5) * rand + }); + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand + }); +} +function gameStateTick(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.coins = gameState.coins.filter((coin)=>!coin.destroyed); + 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); + } + if (remainingBricks <= gameState.perks.skip_last && !gameState.autoCleanUses) { + gameState.bricks.forEach((type, index)=>{ + if (type) explodeBrick(gameState, index, gameState.balls[0], true); + }); + gameState.autoCleanUses++; + } + if (!remainingBricks && !gameState.coins.length) { + if (gameState.currentLevel + 1 < (0, _gameUtils.max_levels)(gameState)) setLevel(gameState, gameState.currentLevel + 1); + else (0, _gameOver.gameOver)((0, _i18N.t)('gameOver.win.title'), (0, _i18N.t)('gameOver.win.summary', { + score: gameState.score + })); + } else if (gameState.running || gameState.levelTime) { + let playedCoinBounce = false; + const coinRadius = Math.round(gameState.coinSize / 2); + gameState.coins.forEach((coin)=>{ + if (coin.destroyed) return; + if (gameState.perks.coin_magnet) { + const attractionX = frames * (gameState.puckPosition - coin.x) / (100 + Math.pow(coin.y - gameState.gameZoneHeight, 2) + Math.pow(coin.x - gameState.puckPosition, 2)) * gameState.perks.coin_magnet * 100; + coin.vx += attractionX; + coin.sa -= attractionX / 10; + } + const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames; + 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 + coin.vy += frames * coin.weight * 0.8; + const speed = Math.abs(coin.sx) + Math.abs(coin.sx); + const hitBorder = (0, _game.bordersHitCheck)(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 + gameState.puckHeight) addToScore(gameState, coin); + else if (coin.y > gameState.canvasHeight + coinRadius) { + coin.destroyed = true; + if (gameState.perks.compound_interest) resetCombo(gameState, coin.x, coin.y); + } + const hitBrick = (0, _game.coinBrickHitCheck)(coin); + if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { + if (gameState.bricks[hitBrick] && coin.color !== gameState.bricks[hitBrick] && gameState.bricks[hitBrick] !== "black" && !coin.coloredABrick) { + gameState.bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + (0, _sounds.sounds).colorChange(coin.x, 0.3); + } + } + if (typeof hitBrick !== "undefined" || hitBorder) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20 && !playedCoinBounce) { + playedCoinBounce = true; + (0, _sounds.sounds).coinBounce(coin.x, 0.2); + } + if (Math.abs(coin.vy) < 3) coin.vy = 0; + } + }); + gameState.balls.forEach((ball)=>ballTick(gameState, ball, frames)); + 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) gameState.flashes.push({ + type: "particle", + duration: 150, + ethereal: true, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + x: gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, + y: Math.random() * gameState.gameZoneHeight, + vx: windD * 8, + vy: 0 + }); + } + gameState.flashes.forEach((flash)=>{ + if (flash.type === "particle") { + flash.x += flash.vx * frames; + flash.y += flash.vy * frames; + if (!flash.ethereal) { + flash.vy += 0.5; + if ((0, _game.hasBrick)((0, _game.brickIndex)(flash.x, flash.y))) flash.destroyed = true; + } + } + }); + } + if (gameState.combo > baseCombo(gameState)) { + // The red should still be visible on a white bg + const baseParticle = !(0, _options.isOptionOn)("basic") && (gameState.combo - baseCombo(gameState)) * Math.random() > 5 && gameState.running && { + type: "particle", + duration: 100 * (Math.random() + 1), + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: "red", + ethereal: true + }; + if (gameState.perks.top_is_lava) baseParticle && gameState.flashes.push({ + ...baseParticle, + x: gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, + y: 0, + vx: (Math.random() - 0.5) * 10, + vy: 5 + }); + if (gameState.perks.left_is_lava && baseParticle) gameState.flashes.push({ + ...baseParticle, + x: gameState.offsetXRoundedDown, + y: Math.random() * gameState.gameZoneHeight, + vx: 5, + vy: (Math.random() - 0.5) * 10 + }); + if (gameState.perks.right_is_lava && baseParticle) gameState.flashes.push({ + ...baseParticle, + x: gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, + y: Math.random() * gameState.gameZoneHeight, + vx: -5, + vy: (Math.random() - 0.5) * 10 + }); + 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); + baseParticle && gameState.flashes.push({ + ...baseParticle, + x, + y: gameState.gameZoneHeight, + vx: (Math.random() - 0.5) * 10, + vy: -5 + }); + } + if (gameState.perks.streak_shots) { + const pos = 0.5 - Math.random(); + baseParticle && gameState.flashes.push({ + ...baseParticle, + duration: 100, + x: gameState.puckPosition + gameState.puckWidth * pos, + y: gameState.gameZoneHeight - gameState.puckHeight, + vx: pos * 10, + vy: -5 + }); + } + } +} +function ballTick(gameState, ball, delta) { + 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; + if ((0, _gameUtils.isTelekinesisActive)(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += (gameState.puckPosition - ball.x) / 1000 * delta * gameState.perks.telekinesis; + } + 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_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 + 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); + if (gameState.perks.respawn && ball.hitItem?.length > 1 && !(0, _options.isOptionOn)("basic")) for(let i = 0; i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; i++){ + const { index, color } = ball.hitItem[i]; + if (gameState.bricks[index] || color === "black") continue; + const vertical = Math.random() > 0.5; + const dx = Math.random() > 0.5 ? 1 : -1; + const dy = Math.random() > 0.5 ? 1 : -1; + gameState.flashes.push({ + type: "particle", + duration: 250, + ethereal: true, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color, + x: (0, _gameUtils.brickCenterX)(gameState, index) + dx * gameState.brickWidth / 2, + y: (0, _gameUtils.brickCenterY)(gameState, index) + dy * gameState.brickWidth / 2, + vx: vertical ? 0 : -dx * gameState.baseSpeed, + vy: vertical ? -dy * gameState.baseSpeed : 0 + }); + } + const borderHitCode = (0, _game.bordersHitCheck)(ball, gameState.ballSize / 2, delta); + 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); + (0, _sounds.sounds).wallBeep(ball.x); + ball.bouncesList?.push({ + x: ball.previousX, + y: ball.previousY + }); + } + // 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); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + (0, _sounds.sounds).wallBeep(ball.x); + } else { + ball.vy *= -1; + gameState.perks.extra_life = Math.max(0, gameState.perks.extra_life - 1); + (0, _sounds.sounds).lifeLost(ball.x); + if (!(0, _options.isOptionOn)("basic")) for(let i = 0; i < 10; i++)gameState.flashes.push({ + type: "particle", + ethereal: false, + color: "red", + destroyed: false, + duration: 150, + size: gameState.coinSize / 2, + time: gameState.levelTime, + x: ball.x, + y: ball.y, + vx: Math.random() * gameState.baseSpeed * 3, + vy: gameState.baseSpeed * 3 + }); + } + if (gameState.perks.streak_shots) resetCombo(gameState, ball.x, ball.y); + if (gameState.perks.respawn) ball.hitItem.slice(0, -1).slice(0, gameState.perks.respawn).forEach(({ index, color })=>{ + if (!gameState.bricks[index] && color !== "black") gameState.bricks[index] = color; + }); + ball.hitItem = []; + if (!ball.hitSinceBounce) { + gameState.runStatistics.misses++; + gameState.levelMisses++; + resetCombo(gameState, ball.x, ball.y); + gameState.flashes.push({ + type: "text", + text: (0, _i18N.t)('play.missed_ball'), + duration: 500, + time: gameState.levelTime, + size: gameState.puckHeight * 1.5, + color: "red", + x: gameState.puckPosition, + y: gameState.gameZoneHeight - gameState.puckHeight * 2 + }); + } + gameState.runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.sapperUses = 0; + ball.piercedSinceBounce = 0; + ball.bouncesList = [ + { + x: ball.previousX, + y: ball.previousY + } + ]; + } + if (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 && gameState.running) { + ball.destroyed = true; + gameState.runStatistics.balls_lost++; + if (!gameState.balls.find((b)=>!b.destroyed)) (0, _gameOver.gameOver)((0, _i18N.t)('gameOver.lost.title'), (0, _i18N.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 = (0, _game.hitsSomething)(previousX, y, radius); + const hhit = (0, _game.hitsSomething)(x, previousY, radius); + const chit = typeof vhit == "undefined" && typeof hhit == "undefined" && (0, _game.hitsSomething)(x, y, radius) || undefined; + const hitBrick = vhit ?? hhit ?? chit; + let sturdyBounce = hitBrick && gameState.bricks[hitBrick] !== "black" && gameState.perks.sturdy_bricks && gameState.perks.sturdy_bricks > Math.random() * 5; + let pierce = false; + if (sturdyBounce || typeof hitBrick === "undefined") ; + else if ((0, _game.shouldPierceByColor)(vhit, hhit, chit)) pierce = true; + else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { + pierce = true; + ball.piercedSinceBounce++; + } + 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 (sturdyBounce) { + (0, _sounds.sounds).wallBeep(x); + return; + } + if (typeof hitBrick !== "undefined") { + const initialBrickColor = gameState.bricks[hitBrick]; + 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]) { + gameState.bricks[hitBrick] = "black"; + ball.sapperUses++; + } + } + if (!(0, _options.isOptionOn)("basic")) { + ball.sparks += delta * (gameState.combo - 1) / 30; + if (ball.sparks > 1) { + gameState.flashes.push({ + type: "particle", + duration: 100 * ball.sparks, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: gameState.ballsColor, + x: ball.x, + y: ball.y, + vx: (Math.random() - 0.5) * gameState.baseSpeed, + vy: (Math.random() - 0.5) * gameState.baseSpeed, + ethereal: false + }); + ball.sparks = 0; + } + } +} -},{"./sounds":"dQKPV","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"kRstf":[function(require,module,exports,__globalThis) { -if ("serviceWorker" in navigator && window.location.search.includes("isPWA=true")) // @ts-ignore -navigator.serviceWorker.register(require("994b1835e761d5ae")); +},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./sounds":"dQKPV","./game_utils":"cEeac","./i18n/i18n":"eNPRm","./loadGameData":"l1B4x","./settings":"5blfu","./render":"9AS2t","./gameOver":"caCAf","./game":"edeGs","./recording":"godmD","./options":"d5NoS"}],"9AS2t":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "gameCanvas", ()=>gameCanvas); +parcelHelpers.export(exports, "ctx", ()=>ctx); +parcelHelpers.export(exports, "bombSVG", ()=>bombSVG); +parcelHelpers.export(exports, "background", ()=>background); +parcelHelpers.export(exports, "backgroundCanvas", ()=>backgroundCanvas); +parcelHelpers.export(exports, "render", ()=>render); +parcelHelpers.export(exports, "renderAllBricks", ()=>renderAllBricks); +parcelHelpers.export(exports, "drawPuck", ()=>drawPuck); +parcelHelpers.export(exports, "drawBall", ()=>drawBall); +parcelHelpers.export(exports, "drawCoin", ()=>drawCoin); +parcelHelpers.export(exports, "drawFuzzyBall", ()=>drawFuzzyBall); +parcelHelpers.export(exports, "drawBrick", ()=>drawBrick); +parcelHelpers.export(exports, "roundRect", ()=>roundRect); +parcelHelpers.export(exports, "drawIMG", ()=>drawIMG); +parcelHelpers.export(exports, "drawText", ()=>drawText); +parcelHelpers.export(exports, "scoreDisplay", ()=>scoreDisplay); +var _gameStateMutators = require("./gameStateMutators"); +var _gameUtils = require("./game_utils"); +var _i18N = require("./i18n/i18n"); +var _game = require("./game"); +var _options = require("./options"); +const gameCanvas = document.getElementById("game"); +const ctx = gameCanvas.getContext("2d", { + alpha: false +}); +const bombSVG = document.createElement("img"); +const background = document.createElement("img"); +const backgroundCanvas = document.createElement("canvas"); +function render(gameState) { + const level = (0, _gameUtils.currentLevelInfo)(gameState); + const { width, height } = gameCanvas; + if (!width || !height) return; + if (gameState.currentLevel || gameState.levelTime) menuLabel.innerText = (0, _i18N.t)('play.current_lvl', { + level: gameState.currentLevel + 1, + max: (0, _gameUtils.max_levels)(gameState) + }); + else menuLabel.innerText = (0, _i18N.t)('play.menu_label'); + scoreDisplay.innerText = `$${gameState.score}`; + scoreDisplay.className = gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : ""; + // Clear + if (!(0, _options.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; + gameState.coins.forEach((coin)=>{ + if (!coin.destroyed) 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 = (0, _gameUtils.brickCenterX)(gameState, index), y = (0, _gameUtils.brickCenterY)(gameState, index); + drawFuzzyBall(ctx, color == "black" ? "#666" : color, gameState.brickWidth, x, y); + }); + ctx.globalAlpha = 1; + gameState.flashes.forEach((flash)=>{ + const { x, y, time, color, size, type, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - elapsed / duration * 2); + if (type === "ball") drawFuzzyBall(ctx, color, size, x, y); + if (type === "particle") 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"); + bgctx.fillStyle = level.color || "#000"; + bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight); + 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); + gameState.flashes.forEach((flash)=>{ + const { x, y, time, color, size, type, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - elapsed / duration * 2); + if (type === "particle") drawBall(ctx, color, size, x, y); + }); + } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5; + const shaked = lastExplosionDelay < 200 && !(0, _options.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 && !(0, _options.isOptionOn)('basic')) { + if (shaked) gameCanvas.style.filter = 'brightness(' + (1 + 100 / (1 + lastExplosionDelay)) + ')'; + else gameCanvas.style.filter = ''; + } + // Coins + ctx.globalAlpha = 1; + gameState.coins.forEach((coin)=>{ + if (!coin.destroyed) { + ctx.globalCompositeOperation = coin.color === "gold" || level.color ? "source-over" : "screen"; + drawCoin(ctx, coin.color, coin.size, coin.x, coin.y, level.color || "black", coin.a); + } + }); + // Black shadow around balls + if (!(0, _options.isOptionOn)("basic")) { + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 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"; + gameState.flashes = gameState.flashes.filter((f)=>gameState.levelTime - f.time < f.duration && !f.destroyed); + gameState.flashes.forEach((flash)=>{ + const { x, y, time, color, size, type, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - elapsed / duration * 2)); + if (type === "text") { + ctx.globalCompositeOperation = "source-over"; + drawText(ctx, flash.text, color, size, x, y - elapsed / 10); + } else if (type === "particle") { + 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.offsetXRoundedDown, gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, gameState.gameZoneWidthRoundedUp, 1); + } + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + gameState.balls.forEach((ball)=>{ + // The white border around is to distinguish colored balls from coins/bg + drawBall(ctx, gameState.ballsColor, gameState.ballSize, ball.x, ball.y, gameState.puckColor); + if ((0, _gameUtils.isTelekinesisActive)(gameState, ball)) { + ctx.strokeStyle = gameState.puckColor; + ctx.beginPath(); + ctx.bezierCurveTo(gameState.puckPosition, gameState.gameZoneHeight, gameState.puckPosition, ball.y, ball.x, ball.y); + ctx.stroke(); + } + }); + // The puck + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + if (gameState.perks.streak_shots && gameState.combo > (0, _gameStateMutators.baseCombo)(gameState)) drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2); + drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight); + 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, comboText, "#FFF", gameState.puckHeight, gameState.puckPosition, gameState.gameZoneHeight - gameState.puckHeight / 2, false); + } + // Borders + const hasCombo = gameState.combo > (0, _gameStateMutators.baseCombo)(gameState); + ctx.globalCompositeOperation = "source-over"; + 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; + ctx.fillRect(gameState.offsetX - 1, 0, 1, height); + ctx.fillStyle = hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; + ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && gameState.perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); + } + if (gameState.perks.top_is_lava && gameState.combo > (0, _gameStateMutators.baseCombo)(gameState)) { + ctx.fillStyle = "red"; + ctx.fillRect(gameState.offsetXRoundedDown, 0, gameState.gameZoneWidthRoundedUp, 1); + } + const redBottom = gameState.perks.compound_interest && gameState.combo > (0, _gameStateMutators.baseCombo)(gameState); + ctx.fillStyle = redBottom ? "red" : gameState.puckColor; + if ((0, _options.isOptionOn)("mobile-mode")) { + ctx.fillRect(gameState.offsetXRoundedDown, gameState.gameZoneHeight, gameState.gameZoneWidthRoundedUp, 1); + if (!gameState.running) drawText(ctx, (0, _i18N.t)('play.mobile_press_to_play'), gameState.puckColor, gameState.puckHeight, gameState.canvasWidth / 2, gameState.gameZoneHeight + (gameState.canvasHeight - gameState.gameZoneHeight) / 2); + } else if (redBottom) ctx.fillRect(gameState.offsetXRoundedDown, gameState.gameZoneHeight - 1, gameState.gameZoneWidthRoundedUp, 1); + if (shaked) ctx.resetTransform(); +} +let cachedBricksRender = document.createElement("canvas"); +let cachedBricksRenderKey = ""; +function renderAllBricks() { + ctx.globalAlpha = 1; + const redBorderOnBricksWithWrongColor = (0, _game.gameState).combo > (0, _gameStateMutators.baseCombo)((0, _game.gameState)) && (0, _game.gameState).perks.picky_eater && !(0, _options.isOptionOn)('basic'); + const newKey = (0, _game.gameState).gameZoneWidth + "_" + (0, _game.gameState).bricks.join("_") + bombSVG.complete + "_" + redBorderOnBricksWithWrongColor + "_" + (0, _game.gameState).ballsColor + "_" + (0, _game.gameState).perks.pierce_color; + if (newKey !== cachedBricksRenderKey) { + cachedBricksRenderKey = newKey; + cachedBricksRender.width = (0, _game.gameState).gameZoneWidth; + cachedBricksRender.height = (0, _game.gameState).gameZoneWidth + 1; + const canctx = cachedBricksRender.getContext("2d"); + canctx.clearRect(0, 0, (0, _game.gameState).gameZoneWidth, (0, _game.gameState).gameZoneWidth); + canctx.resetTransform(); + canctx.translate(-(0, _game.gameState).offsetX, 0); + // Bricks + (0, _game.gameState).bricks.forEach((color, index)=>{ + const x = (0, _gameUtils.brickCenterX)((0, _game.gameState), index), y = (0, _gameUtils.brickCenterY)((0, _game.gameState), index); + if (!color) return; + const borderColor = (0, _game.gameState).ballsColor !== color && color !== "black" && redBorderOnBricksWithWrongColor && "red" || color; + drawBrick(canctx, color, borderColor, x, y); + if (color === "black") { + canctx.globalCompositeOperation = "source-over"; + drawIMG(canctx, bombSVG, (0, _game.gameState).brickWidth, x, y); + } + }); + } + ctx.drawImage(cachedBricksRender, (0, _game.gameState).offsetX, 0); +} +let cachedGraphics = {}; +function drawPuck(ctx, color, puckWidth, puckHeight, yOffset = 0) { + const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = puckWidth; + can.height = puckHeight * 2; + const canctx = can.getContext("2d"); + canctx.fillStyle = color; + canctx.beginPath(); + canctx.moveTo(0, puckHeight * 2); + 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(); + cachedGraphics[key] = can; + } + ctx.drawImage(cachedGraphics[key], Math.round((0, _game.gameState).puckPosition - puckWidth / 2), (0, _game.gameState).gameZoneHeight - puckHeight * 2 + yOffset); +} +function drawBall(ctx, color, width, x, y, 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 canctx = can.getContext("2d"); + 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; + } + ctx.drawImage(cachedGraphics[key], Math.round(x - size / 2), Math.round(y - size / 2)); +} +const angles = 32; +function drawCoin(ctx, color, size, x, y, borderColor, rawAngle) { + 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; + const canctx = can.getContext("2d"); + // coin + canctx.beginPath(); + canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); + if (color === "gold") { + canctx.strokeStyle = borderColor; + canctx.stroke(); + 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)); +} +function drawFuzzyBall(ctx, color, width, x, y) { + 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"); + 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)); +} +function drawBrick(ctx, color, borderColor, x, y) { + const tlx = Math.ceil(x - (0, _game.gameState).brickWidth / 2); + const tly = Math.ceil(y - (0, _game.gameState).brickWidth / 2); + const brx = Math.ceil(x + (0, _game.gameState).brickWidth / 2) - 1; + const bry = Math.ceil(y + (0, _game.gameState).brickWidth / 2) - 1; + const width = brx - tlx, height = bry - tly; + const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = width; + can.height = height; + const bord = 2; + const cornerRadius = 2; + const canctx = can.getContext("2d"); + canctx.fillStyle = color; + canctx.strokeStyle = borderColor; + canctx.lineJoin = "round"; + canctx.lineWidth = bord; + roundRect(canctx, bord / 2, bord / 2, width - bord, height - bord, cornerRadius); + canctx.fill(); + 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 +} +function roundRect(ctx, x, y, width, height, radius) { + 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(); +} +function drawIMG(ctx, img, size, x, y) { + const key = "svg" + img + "_" + size + "_" + img.complete; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; + const canctx = can.getContext("2d"); + 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)); +} +function drawText(ctx, text, color, fontSize, x, y, left = false) { + 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"); + 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); + cachedGraphics[key] = can; + } + ctx.drawImage(cachedGraphics[key], left ? x : Math.round(x - cachedGraphics[key].width / 2), Math.round(y - cachedGraphics[key].height / 2)); +} +const scoreDisplay = document.getElementById("score"); +const menuLabel = document.getElementById("menuLabel"); -},{"994b1835e761d5ae":"6vb6r"}],"6vb6r":[function(require,module,exports,__globalThis) { -module.exports = require("e4f4efefa01a2f07").getBundleURL('28aWT') + "sw-b71.js"; +},{"./gameStateMutators":"9ZeQl","./game_utils":"cEeac","./i18n/i18n":"eNPRm","./game":"edeGs","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./options":"d5NoS"}],"caCAf":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "getUpgraderUnlockPoints", ()=>getUpgraderUnlockPoints); +parcelHelpers.export(exports, "addToTotalPlayTime", ()=>addToTotalPlayTime); +parcelHelpers.export(exports, "gameOver", ()=>gameOver); +parcelHelpers.export(exports, "getHistograms", ()=>getHistograms); +var _loadGameData = require("./loadGameData"); +var _i18N = require("./i18n/i18n"); +var _game = require("./game"); +var _gameUtils = require("./game_utils"); +var _settings = require("./settings"); +var _recording = require("./recording"); +var _asyncAlert = require("./asyncAlert"); +function getUpgraderUnlockPoints() { + let list = []; + (0, _loadGameData.upgrades).forEach((u)=>{ + if (u.threshold) list.push({ + threshold: u.threshold, + title: u.name + ' ' + (0, _i18N.t)('level_up.unlocked_perk') + }); + }); + (0, _loadGameData.allLevels).forEach((l)=>{ + list.push({ + threshold: l.threshold, + title: l.name + ' ' + (0, _i18N.t)('level_up.unlocked_level') + }); + }); + return list.filter((o)=>o.threshold).sort((a, b)=>a.threshold - b.threshold); +} +function addToTotalPlayTime(ms) { + try { + localStorage.setItem("breakout_71_total_play_time", JSON.stringify(JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + ms)); + } catch (e) {} +} +function gameOver(title, intro) { + if (!(0, _game.gameState).running) return; + (0, _game.pause)(true); + (0, _recording.stopRecording)(); + addToTotalPlayTime((0, _game.gameState).runStatistics.runTime); + (0, _game.gameState).runStatistics.max_level = (0, _game.gameState).currentLevel + 1; + let animationDelay = -300; + const getDelay = ()=>{ + animationDelay += 800; + return "animation-delay:" + animationDelay + "ms;"; + }; + // unlocks + let unlocksInfo = ""; + const endTs = (0, _settings.getTotalScore)(); + const startTs = endTs - (0, _game.gameState).score; + const list = getUpgraderUnlockPoints(); + list.filter((u)=>u.threshold > startTs && u.threshold < endTs).forEach((u)=>{ + unlocksInfo += ` +

+ ${u.title} + +

+`; + }); + const previousUnlockAt = (0, _gameUtils.findLast)(list, (u)=>u.threshold <= endTs)?.threshold || 0; + const nextUnlock = list.find((u)=>u.threshold > endTs); + if (nextUnlock) { + const total = nextUnlock?.threshold - previousUnlockAt; + const done = endTs - previousUnlockAt; + intro += (0, _i18N.t)('gameOver.next_unlock', { + points: nextUnlock.threshold - endTs + }); + const scaleX = (done / total).toFixed(2); + unlocksInfo += ` +

+ ${nextUnlock.title} + +

-},{"e4f4efefa01a2f07":"lgJ39"}]},["fxnks","bCo5X"], "bCo5X", "parcelRequire94c2") +`; + list.slice(list.indexOf(nextUnlock) + 1).slice(0, 3).forEach((u)=>{ + unlocksInfo += ` +

+ ${u.title} +

+`; + }); + } + let unlockedItems = list.filter((u)=>u.threshold > startTs && u.threshold < endTs); + if (unlockedItems.length) unlocksInfo += `

${(0, _i18N.t)('gameOver.unlocked_count', { + count: unlockedItems.length + })} ${unlockedItems.map((u)=>u.title).join(", ")}

`; + // Avoid the sad sound right as we restart a new games + (0, _game.gameState).combo = 1; + (0, _asyncAlert.asyncAlert)({ + allowClose: true, + title, + text: ` + ${(0, _game.gameState).isCreativeModeRun ? `

${(0, _i18N.t)('gameOver.test_run')}

` : ""} +

${intro}

+

${(0, _i18N.t)('gameOver.cumulative_total', { + startTs, + endTs + })}

+ ${unlocksInfo} + `, + actions: [ + { + value: null, + text: (0, _i18N.t)('gameOver.restart'), + help: "" + } + ], + textAfterButtons: `
+ ${getHistograms()} + ` + }).then(()=>(0, _game.restart)({ + levelToAvoid: (0, _gameUtils.currentLevelInfo)((0, _game.gameState)).name + })); +} +function getHistograms() { + let runStats = ""; + try { + // Stores only top 100 runs + let runsHistory = JSON.parse(localStorage.getItem("breakout_71_runs_history") || "[]"); + runsHistory.sort((a, b)=>a.score - b.score).reverse(); + runsHistory = runsHistory.slice(0, 100); + runsHistory.push({ + ...(0, _game.gameState).runStatistics, + perks: (0, _game.gameState).perks, + appVersion: (0, _loadGameData.appVersion) + }); + // Generate some histogram + if (!(0, _game.gameState).isCreativeModeRun) localStorage.setItem("breakout_71_runs_history", JSON.stringify(runsHistory, null, 2)); + const makeHistogram = (title, getter, unit)=>{ + let values = runsHistory.map((h)=>getter(h) || 0); + let min = Math.min(...values); + let max = Math.max(...values); + // No point + if (min === max) return ""; + if (max - min < 10) { + // This is mostly useful for levels + min = Math.max(0, max - 10); + max = Math.max(max, min + 10); + } + // One bin per unique value, max 10 + const binsCount = Math.min(values.length, 10); + if (binsCount < 3) return ""; + const bins = []; + const binsTotal = []; + for(let i = 0; i < binsCount; i++){ + bins.push(0); + binsTotal.push(0); + } + const binSize = (max - min) / bins.length; + const binIndexOf = (v)=>Math.min(bins.length - 1, Math.floor((v - min) / binSize)); + values.forEach((v)=>{ + if (isNaN(v)) return; + const index = binIndexOf(v); + bins[index]++; + binsTotal[index] += v; + }); + if (bins.filter((b)=>b).length < 3) return ""; + const maxBin = Math.max(...bins); + const lastValue = values[values.length - 1]; + const activeBin = binIndexOf(lastValue); + const bars = bins.map((v, vi)=>{ + const style = `height: ${v / maxBin * 80}px`; + return `${!v && " " || vi == activeBin && lastValue + unit || Math.round(binsTotal[vi] / v) + unit}`; + }).join(""); + return `

${title} : ${lastValue}${unit}

+
${bars}
+ `; + }; + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.total_score'), (r)=>r.score, ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.catch_rate'), (r)=>Math.round(r.score / r.coins_spawned * 100), "%"); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.bricks_broken'), (r)=>r.bricks_broken, ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.bricks_per_minute'), (r)=>Math.round(r.bricks_broken / r.runTime * 60000), ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.hit_rate'), (r)=>Math.round((1 - r.misses / r.puck_bounces) * 100), "%"); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.duration_per_level'), (r)=>Math.round(r.runTime / 1000 / r.levelsPlayed), "s"); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.level_reached'), (r)=>r.levelsPlayed, ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.upgrades_applied'), (r)=>r.upgrades_picked, ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.balls_lost'), (r)=>r.balls_lost, ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.combo_avg'), (r)=>Math.round(r.coins_spawned / r.bricks_broken), ""); + runStats += makeHistogram((0, _i18N.t)('gameOver.stats.combo_max'), (r)=>r.max_combo, ""); + if (runStats) runStats = `

${(0, _i18N.t)('gameOver.stats.intro', { + count: runsHistory.length - 1 + })}

` + runStats; + } catch (e) { + console.warn(e); + } + return runStats; +} + +},{"./loadGameData":"l1B4x","./i18n/i18n":"eNPRm","./game":"edeGs","./game_utils":"cEeac","./settings":"5blfu","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./recording":"godmD","./asyncAlert":"rSqLY"}],"godmD":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "recordOneFrame", ()=>recordOneFrame); +parcelHelpers.export(exports, "drawMainCanvasOnSmallCanvas", ()=>drawMainCanvasOnSmallCanvas); +parcelHelpers.export(exports, "startRecordingGame", ()=>startRecordingGame); +parcelHelpers.export(exports, "pauseRecording", ()=>pauseRecording); +parcelHelpers.export(exports, "resumeRecording", ()=>resumeRecording); +parcelHelpers.export(exports, "stopRecording", ()=>stopRecording); +parcelHelpers.export(exports, "captureFileName", ()=>captureFileName); +var _render = require("./render"); +var _gameUtils = require("./game_utils"); +var _sounds = require("./sounds"); +var _i18N = require("./i18n/i18n"); +var _options = require("./options"); +let mediaRecorder, captureStream, captureTrack, recordCanvas, recordCanvasCtx; +function recordOneFrame(gameState) { + if (!(0, _options.isOptionOn)("record")) return; + if (!gameState.running) return; + if (!captureStream) return; + drawMainCanvasOnSmallCanvas(gameState); + if (captureTrack?.requestFrame) captureTrack?.requestFrame(); + else if (captureStream?.requestFrame) captureStream.requestFrame(); +} +function drawMainCanvasOnSmallCanvas(gameState) { + if (!recordCanvasCtx) return; + recordCanvasCtx.drawImage((0, _render.gameCanvas), gameState.offsetXRoundedDown, 0, gameState.gameZoneWidthRoundedUp, gameState.gameZoneHeight, 0, 0, recordCanvas.width, recordCanvas.height); + // Here we don't use drawText as we don't want to cache a picture for each distinct value of score + recordCanvasCtx.fillStyle = "#FFF"; + recordCanvasCtx.textBaseline = "top"; + recordCanvasCtx.font = "12px monospace"; + recordCanvasCtx.textAlign = "right"; + recordCanvasCtx.fillText(gameState.score.toString(), recordCanvas.width - 12, 12); + recordCanvasCtx.textAlign = "left"; + recordCanvasCtx.fillText("Level " + (gameState.currentLevel + 1) + "/" + (0, _gameUtils.max_levels)(gameState), 12, 12); +} +function startRecordingGame(gameState) { + if (!(0, _options.isOptionOn)("record")) return; + if (mediaRecorder) return; + if (!recordCanvas) { + // Smaller canvas with fewer details + recordCanvas = document.createElement("canvas"); + recordCanvasCtx = recordCanvas.getContext("2d", { + antialias: false, + alpha: false + }); + captureStream = recordCanvas.captureStream(0); + captureTrack = captureStream.getVideoTracks()[0]; + const track = (0, _sounds.getAudioRecordingTrack)(); + if (track) captureStream.addTrack(track.stream.getAudioTracks()[0]); + } + recordCanvas.width = gameState.gameZoneWidthRoundedUp; + recordCanvas.height = gameState.gameZoneHeight; + // drawMainCanvasOnSmallCanvas() + const recordedChunks = []; + const instance = new MediaRecorder(captureStream, { + videoBitsPerSecond: 3500000 + }); + mediaRecorder = instance; + instance.start(); + mediaRecorder.pause(); + instance.ondataavailable = function(event) { + recordedChunks.push(event.data); + }; + instance.onstop = async function() { + let targetDiv; + let blob = new Blob(recordedChunks, { + type: "video/webm" + }); + if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short + while(!(targetDiv = document.getElementById("level-recording-container")))await new Promise((r)=>setTimeout(r, 200)); + const video = document.createElement("video"); + video.autoplay = true; + video.controls = false; + video.disablePictureInPicture = true; + video.disableRemotePlayback = true; + video.width = recordCanvas.width; + video.height = recordCanvas.height; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.src = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.download = captureFileName("webm"); + a.target = "_blank"; + a.href = video.src; + a.textContent = (0, _i18N.t)('main_menu.record_download', { + size: (blob.size / 1000000).toFixed(2) + }); + targetDiv.appendChild(video); + targetDiv.appendChild(a); + }; +} +function pauseRecording() { + if (!(0, _options.isOptionOn)("record")) return; + if (mediaRecorder?.state === "recording") mediaRecorder?.pause(); +} +function resumeRecording() { + if (!(0, _options.isOptionOn)("record")) return; + if (mediaRecorder?.state === "paused") mediaRecorder.resume(); +} +function stopRecording() { + if (!(0, _options.isOptionOn)("record")) return; + if (!mediaRecorder) return; + mediaRecorder?.stop(); + mediaRecorder = null; +} +function captureFileName(ext = "webm") { + return "breakout-71-capture-" + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + "." + ext; +} + +},{"./render":"9AS2t","./game_utils":"cEeac","./sounds":"dQKPV","./i18n/i18n":"eNPRm","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./options":"d5NoS"}],"rSqLY":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "alertsOpen", ()=>alertsOpen); +parcelHelpers.export(exports, "closeModal", ()=>closeModal); +parcelHelpers.export(exports, "asyncAlert", ()=>asyncAlert); +var _i18N = require("./i18n/i18n"); +let alertsOpen = 0, closeModal = null; +function asyncAlert({ title, text, actions, allowClose = true, textAfterButtons = "", actionsAsGrid = false }) { + alertsOpen++; + return new Promise((resolve)=>{ + const popupWrap = document.createElement("div"); + document.body.appendChild(popupWrap); + popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); + function closeWithResult(value) { + resolve(value); + // Doing this async lets the menu scroll persist if it's shown a second time + setTimeout(()=>{ + document.body.removeChild(popupWrap); + }); + } + if (allowClose) { + const closeButton = document.createElement("button"); + closeButton.title = (0, _i18N.t)('play.close_modale_window_tooltip'); + closeButton.className = "close-modale"; + closeButton.addEventListener("click", (e)=>{ + e.preventDefault(); + closeWithResult(undefined); + }); + closeModal = ()=>{ + closeWithResult(undefined); + }; + popupWrap.appendChild(closeButton); + } + const popup = document.createElement("div"); + if (title) { + const p = document.createElement("h2"); + p.innerHTML = title; + popup.appendChild(p); + } + if (text) { + const p = document.createElement("div"); + p.innerHTML = text; + popup.appendChild(p); + } + const buttons = document.createElement("section"); + popup.appendChild(buttons); + actions?.filter((i)=>i).forEach(({ text, value, help, disabled, className = "", icon = "" })=>{ + const button = document.createElement("button"); + button.innerHTML = ` +${icon} +
+ ${text} + ${help || ""} +
`; + if (disabled) button.setAttribute("disabled", "disabled"); + else button.addEventListener("click", (e)=>{ + e.preventDefault(); + closeWithResult(value); + }); + button.className = className; + buttons.appendChild(button); + }); + if (textAfterButtons) { + const p = document.createElement("div"); + p.className = "textAfterButtons"; + p.innerHTML = textAfterButtons; + popup.appendChild(p); + } + popupWrap.appendChild(popup); + popup.querySelector("button:not([disabled])")?.focus(); + }).then((v)=>{ + alertsOpen--; + closeModal = null; + return v; + }, ()=>{ + closeModal = null; + alertsOpen--; + }); +} + +},{"./i18n/i18n":"eNPRm","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"aQN6X":[function(require,module,exports,__globalThis) { +var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); +parcelHelpers.defineInteropFlag(exports); +parcelHelpers.export(exports, "newGameState", ()=>newGameState); +var _settings = require("./settings"); +var _loadGameData = require("./loadGameData"); +var _gameUtils = require("./game_utils"); +var _gameStateMutators = require("./gameStateMutators"); +var _options = require("./options"); +function newGameState(params) { + const totalScoreAtRunStart = (0, _settings.getTotalScore)(); + const firstLevel = params?.level ? (0, _loadGameData.allLevels).filter((l)=>l.name === params?.level) : []; + const restInRandomOrder = (0, _loadGameData.allLevels).filter((l)=>totalScoreAtRunStart >= l.threshold).filter((l)=>l.name !== params?.level).filter((l)=>l.name !== params?.levelToAvoid).sort(()=>Math.random() - 0.5); + const runLevels = firstLevel.concat(restInRandomOrder.slice(0, 10).sort((a, b)=>a.sortKey - b.sortKey)); + const perks = { + ...(0, _gameUtils.makeEmptyPerksMap)((0, _loadGameData.upgrades)), + ...params?.perks || {} + }; + const gameState = { + runLevels, + currentLevel: 0, + perks, + puckWidth: 200, + baseSpeed: 12, + combo: 1, + gridSize: 12, + running: false, + puckPosition: 400, + pauseTimeout: null, + canvasWidth: 0, + canvasHeight: 0, + offsetX: 0, + offsetXRoundedDown: 0, + gameZoneWidth: 0, + gameZoneWidthRoundedUp: 0, + gameZoneHeight: 0, + brickWidth: 0, + score: 0, + lastScoreIncrease: -1000, + lastExplosion: -1000, + highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"), + balls: [], + ballsColor: "white", + bricks: [], + flashes: [], + coins: [], + levelStartScore: 0, + levelMisses: 0, + levelSpawnedCoins: 0, + lastPlayedCoinGrab: 0, + MAX_COINS: 400, + MAX_PARTICLES: 600, + puckColor: "#FFF", + ballSize: 20, + coinSize: 14, + puckHeight: 20, + totalScoreAtRunStart, + isCreativeModeRun: (0, _gameUtils.sumOfKeys)(perks) > 1, + pauseUsesDuringRun: 0, + keyboardPuckSpeed: 0, + lastTick: performance.now(), + lastTickDown: 0, + runStatistics: { + started: Date.now(), + levelsPlayed: 0, + runTime: 0, + coins_spawned: 0, + score: 0, + bricks_broken: 0, + misses: 0, + balls_lost: 0, + puck_bounces: 0, + upgrades_picked: 1, + max_combo: 1, + max_level: 0 + }, + lastOffered: {}, + levelTime: 0, + autoCleanUses: 0 + }; + (0, _gameStateMutators.resetBalls)(gameState); + if (!(0, _gameUtils.sumOfKeys)(gameState.perks)) { + const giftable = (0, _gameUtils.getPossibleUpgrades)(gameState).filter((u)=>u.giftable); + const randomGift = (0, _options.isOptionOn)("easy") && "slow_down" || giftable[Math.floor(Math.random() * giftable.length)].id; + perks[randomGift] = 1; + (0, _gameStateMutators.dontOfferTooSoon)(gameState, randomGift); + } + for (let perk of (0, _loadGameData.upgrades))if (gameState.perks[perk.id]) (0, _gameStateMutators.dontOfferTooSoon)(gameState, perk.id); + return gameState; +} + +},{"./settings":"5blfu","./loadGameData":"l1B4x","./game_utils":"cEeac","./gameStateMutators":"9ZeQl","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./options":"d5NoS"}]},["fxnks","bCo5X"], "bCo5X", "parcelRequire94c2") diff --git a/src/asyncAlert.ts b/src/asyncAlert.ts new file mode 100644 index 0000000..603851c --- /dev/null +++ b/src/asyncAlert.ts @@ -0,0 +1,122 @@ +import {t} from "./i18n/i18n"; + +export let alertsOpen = 0, + closeModal: null | (() => void) = null; + +export type AsyncAlertAction = { + text?: string; + value?: t; + help?: string; + disabled?: boolean; + icon?: string; + className?: string; +}; + + +export function asyncAlert({ + title, + text, + actions, + allowClose = true, + textAfterButtons = "", + actionsAsGrid = false, + }: { + title?: string; + text?: string; + actions?: AsyncAlertAction[]; + textAfterButtons?: string; + allowClose?: boolean; + actionsAsGrid?: boolean; +}): Promise { + alertsOpen++; + return new Promise((resolve) => { + const popupWrap = document.createElement("div"); + document.body.appendChild(popupWrap); + popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); + + function closeWithResult(value: t | undefined) { + resolve(value); + // Doing this async lets the menu scroll persist if it's shown a second time + setTimeout(() => { + document.body.removeChild(popupWrap); + }); + } + + if (allowClose) { + const closeButton = document.createElement("button"); + closeButton.title = t('play.close_modale_window_tooltip'); + closeButton.className = "close-modale"; + closeButton.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(undefined); + }); + closeModal = () => { + closeWithResult(undefined); + }; + popupWrap.appendChild(closeButton); + } + + const popup = document.createElement("div"); + + if (title) { + const p = document.createElement("h2"); + p.innerHTML = title; + popup.appendChild(p); + } + + if (text) { + const p = document.createElement("div"); + p.innerHTML = text; + popup.appendChild(p); + } + + const buttons = document.createElement("section"); + popup.appendChild(buttons); + + actions + ?.filter((i) => i) + .forEach(({text, value, help, disabled, className = "", icon = ""}) => { + const button = document.createElement("button"); + + button.innerHTML = ` +${icon} +
+ ${text} + ${help || ""} +
`; + + if (disabled) { + button.setAttribute("disabled", "disabled"); + } else { + button.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(value); + }); + } + button.className = className; + buttons.appendChild(button); + }); + + if (textAfterButtons) { + const p = document.createElement("div"); + p.className = "textAfterButtons"; + p.innerHTML = textAfterButtons; + popup.appendChild(p); + } + + popupWrap.appendChild(popup); + ( + popup.querySelector("button:not([disabled])") as HTMLButtonElement + )?.focus(); + }).then( + (v: unknown) => { + alertsOpen--; + closeModal = null; + return v as t | undefined; + }, + () => { + closeModal = null; + alertsOpen--; + }, + ); +} \ No newline at end of file diff --git a/src/combo.ts b/src/combo.ts deleted file mode 100644 index ed95a93..0000000 --- a/src/combo.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { GameState } from "./types"; -import { sounds } from "./sounds"; - -export function baseCombo(gameState: GameState) { - return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; -} - -export function resetCombo( - gameState: GameState, - x: number | undefined, - y: number | undefined, -) { - const prev = gameState.combo; - gameState.combo = baseCombo(gameState); - if (!gameState.levelTime) { - gameState.combo += gameState.perks.hot_start * 15; - } - if (prev > gameState.combo && gameState.perks.soft_reset) { - gameState.combo += Math.floor( - (prev - gameState.combo) / (1 + gameState.perks.soft_reset), - ); - } - const lost = Math.max(0, prev - gameState.combo); - if (lost) { - for (let i = 0; i < lost && i < 8; i++) { - setTimeout(() => sounds.comboDecrease(), i * 100); - } - if (typeof x !== "undefined" && typeof y !== "undefined") { - gameState.flashes.push({ - type: "text", - text: "-" + lost, - time: gameState.levelTime, - color: "red", - x: x, - y: y, - duration: 150, - size: gameState.puckHeight, - }); - } - } - return lost; -} - -export function decreaseCombo( - 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); - - if (lost) { - sounds.comboDecrease(); - if (typeof x !== "undefined" && typeof y !== "undefined") { - gameState.flashes.push({ - type: "text", - text: "-" + lost, - time: gameState.levelTime, - color: "red", - x: x, - y: y, - duration: 300, - size: gameState.puckHeight, - }); - } - } -} diff --git a/src/game.ts b/src/game.ts index 7408ab8..f78de79 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,21 +1,27 @@ import {allLevels, appVersion, icons, upgrades} from "./loadGameData"; -import {Ball, BallLike, Coin, colorString, GameState, PerkId, RunHistoryItem, RunParams, Upgrade,} from "./types"; -import {isOptionOn, OptionId, options, toggleOption} from "./options"; -import {getAudioContext, getAudioRecordingTrack, sounds} from "./sounds"; -import {putBallsAtPuck, resetBalls} from "./resetBalls"; -import {makeEmptyPerksMap, sumOfKeys} from "./game_utils"; -import {baseCombo, decreaseCombo, resetCombo} from "./combo"; +import {Ball, Coin, GameState, OptionId, PerkId, RunParams, Upgrade,} from "./types"; +import {getAudioContext} from "./sounds"; +import {currentLevelInfo, getRowColIndex, max_levels, pickedUpgradesHTMl} from "./game_utils"; import "./sw_loader"; import {getCurrentLang, t} from "./i18n/i18n"; -import {getSettingValue, setSettingValue} from "./settings"; +import {getSettingValue, getTotalScore, setSettingValue} from "./settings"; +import { + gameStateTick, + normalizeGameState, + pickRandomUpgrades, + putBallsAtPuck, + resetBalls, + resetCombo, + setLevel, + setMousePos +} from "./gameStateMutators"; +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"; -const gameCanvas = document.getElementById("game") as HTMLCanvasElement; -const ctx = gameCanvas.getContext("2d", { - alpha: false, -}) as CanvasRenderingContext2D; - -const bombSVG = document.createElement("img"); bombSVG.src = "data:image/svg+xml;base64," + btoa(` @@ -26,7 +32,7 @@ export function play() { if (gameState.running) return; gameState.running = true; - startRecordingGame(); + startRecordingGame(gameState); getAudioContext()?.resume(); resumeRecording(); document.body.className = gameState.running ? " running " : " paused "; @@ -39,7 +45,6 @@ export function pause(playerAskedForPause: boolean) { gameState.pauseTimeout = setTimeout( () => { gameState.running = false; - gameState.needsRender = true; setTimeout(() => { if (!gameState.running) getAudioContext()?.suspend(); @@ -63,11 +68,6 @@ export function pause(playerAskedForPause: boolean) { } } -const background = document.createElement("img"); -const backgroundCanvas = document.createElement("canvas"); -background.addEventListener("load", () => { - gameState.needsRender = true; -}); export const fitSize = () => { const {width, height} = gameCanvas.getBoundingClientRect(); @@ -75,7 +75,7 @@ export const fitSize = () => { gameState.canvasHeight = height; gameCanvas.width = width; gameCanvas.height = height; - ctx.fillStyle = currentLevelInfo()?.color || "black"; + ctx.fillStyle = currentLevelInfo(gameState)?.color || "black"; ctx.globalAlpha = 1; ctx.fillRect(0, 0, width, height); backgroundCanvas.width = width; @@ -97,7 +97,7 @@ export const fitSize = () => { gameState.gameZoneWidthRoundedUp = width - 2 * gameState.offsetXRoundedDown; backgroundCanvas.title = "resized"; // Ensure puck stays within bounds - setMousePos(gameState.puckPosition); + setMousePos(gameState, gameState.puckPosition); gameState.coins = []; gameState.flashes = []; pause(true); @@ -118,110 +118,8 @@ setInterval(() => { fitSize(); }, 1000); -export function recomputeTargetBaseSpeed() { - // We never want the ball to completely stop, it will move at least 3px per frame - gameState.baseSpeed = Math.max( - 3, - gameState.gameZoneWidth / 12 / 10 + - gameState.currentLevel / 3 + - gameState.levelTime / (30 * 1000) - - gameState.perks.slow_down * 2, - ); -} -export function brickCenterX(index: number) { - return ( - gameState.offsetX + - ((index % gameState.gridSize) + 0.5) * gameState.brickWidth - ); -} - -export function brickCenterY(index: number) { - return (Math.floor(index / gameState.gridSize) + 0.5) * gameState.brickWidth; -} - -export function getRowColIndex(row: number, col: number) { - if ( - row < 0 || - col < 0 || - row >= gameState.gridSize || - col >= gameState.gridSize - ) - return -1; - return row * gameState.gridSize + col; -} - -export function spawnExplosion( - count: number, - x: number, - y: number, - color: string, - duration = 150, - size = gameState.coinSize, -) { - if (!!isOptionOn("basic")) return; - if (gameState.flashes.length > gameState.MAX_PARTICLES) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - gameState.flashes.push({ - type: "particle", - time: gameState.levelTime, - size, - x: x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - y: y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - vx: (Math.random() - 0.5) * 30, - vy: (Math.random() - 0.5) * 30, - color, - duration, - ethereal: false, - }); - } -} - -export function addToScore(coin: Coin) { - coin.destroyed = true; - gameState.score += coin.points; - gameState.lastScoreIncrease = gameState.levelTime; - - addToTotalScore(coin.points); - if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { - gameState.highScore = gameState.score; - localStorage.setItem("breakout-3-hs", gameState.score.toString()); - } - if (!isOptionOn("basic")) { - gameState.flashes.push({ - type: "particle", - duration: 100 + Math.random() * 50, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: coin.color, - x: coin.previousX, - y: coin.previousY, - vx: (gameState.canvasWidth - coin.x) / 100, - vy: -coin.y / 100, - ethereal: true, - }); - } - - if (Date.now() - gameState.lastPlayedCoinGrab > 16) { - gameState.lastPlayedCoinGrab = Date.now(); - sounds.coinCatch(coin.x); - } - gameState.runStatistics.score += coin.points; -} - -export function pickedUpgradesHTMl() { - let list = ""; - for (let u of upgrades) { - for (let i = 0; i < gameState.perks[u.id]; i++) - list += icons["icon:" + u.id] + " "; - } - return list; -} - -async function openUpgradesPicker() { +export async function openUpgradesPicker(gameState:GameState) { const catchRate = (gameState.score - gameState.levelStartScore) / (gameState.levelSpawnedCoins || 1); @@ -258,7 +156,7 @@ async function openUpgradesPicker() { } while (repeats--) { - const actions = pickRandomUpgrades( + const actions = pickRandomUpgrades(gameState, choices + gameState.perks.one_more_choice - gameState.perks.instant_upgrade, @@ -267,9 +165,9 @@ async function openUpgradesPicker() { let textAfterButtons = `

${t('level_up.after_buttons', { level: gameState.currentLevel + 1, - max: max_levels() + max: max_levels(gameState) })}

-

${pickedUpgradesHTMl()}

+

${pickedUpgradesHTMl(gameState)}

`; @@ -306,133 +204,6 @@ async function openUpgradesPicker() { resetBalls(gameState); } -export function setLevel(l: number) { - stopRecording(); - pause(false); - if (l > 0) { - openUpgradesPicker(); - } - gameState.currentLevel = l; - - gameState.levelTime = 0; - gameState.autoCleanUses = 0; - gameState.lastTickDown = gameState.levelTime; - gameState.levelStartScore = gameState.score; - gameState.levelSpawnedCoins = 0; - gameState.levelMisses = 0; - gameState.runStatistics.levelsPlayed++; - - resetCombo(gameState, undefined, undefined); - recomputeTargetBaseSpeed(); - resetBalls(gameState); - - const lvl = currentLevelInfo(); - if (lvl.size !== gameState.gridSize) { - gameState.gridSize = lvl.size; - fitSize(); - } - gameState.coins = []; - gameState.bricks = [...lvl.bricks]; - gameState.flashes = []; - - // 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; -} - -export function currentLevelInfo() { - return gameState.runLevels[ - gameState.currentLevel % gameState.runLevels.length - ]; -} - -export function getPossibleUpgrades(gameState: GameState) { - return upgrades - .filter((u) => gameState.totalScoreAtRunStart >= u.threshold) - .filter((u) => !u?.requires || gameState.perks[u?.requires]); -} - -export function getUpgraderUnlockPoints() { - let list = [] as { threshold: number; title: string }[]; - - upgrades.forEach((u) => { - if (u.threshold) { - list.push({ - threshold: u.threshold, - title: u.name + ' ' + t('level_up.unlocked_perk'), - }); - } - }); - - allLevels.forEach((l) => { - list.push({ - threshold: l.threshold, - title: l.name + ' ' + t('level_up.unlocked_level'), - }); - }); - - return list - .filter((o) => o.threshold) - .sort((a, b) => a.threshold - b.threshold); -} - -export function dontOfferTooSoon(gameState: GameState, id: PerkId) { - gameState.lastOffered[id] = Math.round(Date.now() / 1000); -} - -export function pickRandomUpgrades(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) - .slice(0, count) - .sort((a, b) => (a.id > b.id ? 1 : -1)); - - 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), - })); -} - -export function setMousePos(x: number) { - gameState.needsRender = true; - gameState.puckPosition = x; - - // We have borders visible, enforce them - if ( - gameState.puckPosition < - gameState.offsetXRoundedDown + gameState.puckWidth / 2 - ) { - gameState.puckPosition = - gameState.offsetXRoundedDown + gameState.puckWidth / 2; - } - if ( - gameState.puckPosition > - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp - - gameState.puckWidth / 2 - ) { - gameState.puckPosition = - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp - - gameState.puckWidth / 2; - } - if (!gameState.running && !gameState.levelTime) { - putBallsAtPuck(gameState); - } -} - gameCanvas.addEventListener("mouseup", (e) => { if (e.button !== 0) return; if (gameState.running) { @@ -447,16 +218,16 @@ gameCanvas.addEventListener("mouseup", (e) => { gameCanvas.addEventListener("mousemove", (e) => { if (document.pointerLockElement === gameCanvas) { - setMousePos(gameState.puckPosition + e.movementX); + setMousePos(gameState, gameState.puckPosition + e.movementX); } else { - setMousePos(e.x); + setMousePos(gameState, e.x); } }); gameCanvas.addEventListener("touchstart", (e) => { e.preventDefault(); if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + setMousePos(gameState, e.touches[0].pageX); play(); }); gameCanvas.addEventListener("touchend", (e) => { @@ -466,15 +237,14 @@ gameCanvas.addEventListener("touchend", (e) => { gameCanvas.addEventListener("touchcancel", (e) => { e.preventDefault(); pause(true); - gameState.needsRender = true; }); gameCanvas.addEventListener("touchmove", (e) => { if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + setMousePos(gameState, e.touches[0].pageX); }); export function brickIndex(x: number, y: number) { - return getRowColIndex( + return getRowColIndex(gameState, Math.floor(y / gameState.brickWidth), Math.floor((x - gameState.offsetX) / gameState.brickWidth), ); @@ -614,1716 +384,28 @@ export function bordersHitCheck( } export function tick() { - recomputeTargetBaseSpeed(); - const currentTick = performance.now(); - gameState.puckWidth = - (gameState.gameZoneWidth / 12) * - (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); + const currentTick = performance.now(); + const timeDeltaMs = currentTick - gameState.lastTick + gameState.lastTick = currentTick; + + const frames = Math.min(4, (timeDeltaMs) / (1000 / 60)); + if (gameState.keyboardPuckSpeed) { - setMousePos(gameState.puckPosition + gameState.keyboardPuckSpeed); + setMousePos(gameState, gameState.puckPosition + gameState.keyboardPuckSpeed); } + normalizeGameState(gameState) + if (gameState.running) { - gameState.levelTime += currentTick - gameState.lastTick; - gameState.runStatistics.runTime += currentTick - gameState.lastTick; - gameState.runStatistics.max_combo = Math.max( - gameState.runStatistics.max_combo, - gameState.combo, - ); - - // How many times to compute - let delta = Math.min(4, (currentTick - gameState.lastTick) / (1000 / 60)); - delta *= gameState.running ? 1 : 0; - - gameState.coins = gameState.coins.filter((coin) => !coin.destroyed); - 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, - ); - } - - if ( - remainingBricks <= gameState.perks.skip_last && - !gameState.autoCleanUses - ) { - gameState.bricks.forEach((type, index) => { - if (type) { - explodeBrick(index, gameState.balls[0], true); - } - }); - gameState.autoCleanUses++; - } - if (!remainingBricks && !gameState.coins.length) { - if (gameState.currentLevel + 1 < max_levels()) { - setLevel(gameState.currentLevel + 1); - } else { - gameOver( - t('gameOver.win.title'), - t('gameOver.win.summary', {score: gameState.score}), - ); - } - } else if (gameState.running || gameState.levelTime) { - let playedCoinBounce = false; - const coinRadius = Math.round(gameState.coinSize / 2); - - gameState.coins.forEach((coin) => { - if (coin.destroyed) return; - if (gameState.perks.coin_magnet) { - const attractionX = - ((delta * (gameState.puckPosition - coin.x)) / - (100 + - Math.pow(coin.y - gameState.gameZoneHeight, 2) + - Math.pow(coin.x - gameState.puckPosition, 2))) * - gameState.perks.coin_magnet * - 100; - coin.vx += attractionX; - coin.sa -= attractionX / 10; - } - - const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * delta; - - 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 - coin.vy += delta * coin.weight * 0.8; - - const speed = Math.abs(coin.sx) + Math.abs(coin.sx); - const hitBorder = bordersHitCheck(coin, coin.size / 2, delta); - - 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 - gameState.puckHeight - ) { - addToScore(coin); - } else if (coin.y > gameState.canvasHeight + coinRadius) { - coin.destroyed = true; - if (gameState.perks.compound_interest) { - resetCombo(gameState, coin.x, coin.y); - } - } - - const hitBrick = coinBrickHitCheck(coin); - - if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { - if ( - gameState.bricks[hitBrick] && - coin.color !== gameState.bricks[hitBrick] && - gameState.bricks[hitBrick] !== "black" && - !coin.coloredABrick - ) { - gameState.bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - sounds.colorChange(coin.x, 0.3); - } - } - if (typeof hitBrick !== "undefined" || hitBorder) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20 && !playedCoinBounce) { - playedCoinBounce = true; - sounds.coinBounce(coin.x, 0.2); - } - - if (Math.abs(coin.vy) < 3) { - coin.vy = 0; - } - } - }); - - gameState.balls.forEach((ball) => ballTick(ball, delta)); - - 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) { - gameState.flashes.push({ - type: "particle", - duration: 150, - ethereal: true, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - x: - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - y: Math.random() * gameState.gameZoneHeight, - vx: windD * 8, - vy: 0, - }); - } - } - } - - gameState.flashes.forEach((flash) => { - if (flash.type === "particle") { - flash.x += flash.vx * delta; - flash.y += flash.vy * delta; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) { - flash.destroyed = true; - } - } - } - }); - } - - if (gameState.combo > baseCombo(gameState)) { - // The red should still be visible on a white bg - const baseParticle = !isOptionOn("basic") && - (gameState.combo - baseCombo(gameState)) * Math.random() > 5 && - gameState.running && { - type: "particle" as const, - duration: 100 * (Math.random() + 1), - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: "red", - ethereal: true, - }; - - if (gameState.perks.top_is_lava) { - baseParticle && - gameState.flashes.push({ - ...baseParticle, - x: - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - y: 0, - vx: (Math.random() - 0.5) * 10, - vy: 5, - }); - } - - if (gameState.perks.left_is_lava && baseParticle) { - gameState.flashes.push({ - ...baseParticle, - x: gameState.offsetXRoundedDown, - y: Math.random() * gameState.gameZoneHeight, - vx: 5, - vy: (Math.random() - 0.5) * 10, - }); - } - - if (gameState.perks.right_is_lava && baseParticle) { - gameState.flashes.push({ - ...baseParticle, - x: gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, - y: Math.random() * gameState.gameZoneHeight, - vx: -5, - vy: (Math.random() - 0.5) * 10, - }); - } - - 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 - ); - baseParticle && - gameState.flashes.push({ - ...baseParticle, - x, - y: gameState.gameZoneHeight, - vx: (Math.random() - 0.5) * 10, - vy: -5, - }); - } - if (gameState.perks.streak_shots) { - const pos = 0.5 - Math.random(); - baseParticle && - gameState.flashes.push({ - ...baseParticle, - duration: 100, - x: gameState.puckPosition + gameState.puckWidth * pos, - y: gameState.gameZoneHeight - gameState.puckHeight, - vx: pos * 10, - vy: -5, - }); - } - } + gameState.levelTime += timeDeltaMs; + gameState.runStatistics.runTime += timeDeltaMs; + gameStateTick(gameState, frames) } - - render(); - + render(gameState); + recordOneFrame(gameState); requestAnimationFrame(tick); - gameState.lastTick = currentTick; -} - -export function isTelekinesisActive(ball: Ball) { - return gameState.perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; -} - -export function ballTick(ball: Ball, delta: number) { - 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; - if (isTelekinesisActive(ball)) { - speedLimitDampener += 3; - ball.vx += - ((gameState.puckPosition - ball.x) / 1000) * - delta * - gameState.perks.telekinesis; - } - - 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(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(ball, b2, gameState.perks.ball_attract_ball); - } - } - 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( - ball, - { - x: gameState.puckPosition, - y: gameState.gameZoneHeight, - }, - gameState.perks.puck_repulse_ball + 1, - false, - ); - } - - if ( - gameState.perks.respawn && - ball.hitItem?.length > 1 && - !isOptionOn("basic") - ) { - for ( - let i = 0; - i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; - i++ - ) { - const {index, color} = ball.hitItem[i]; - if (gameState.bricks[index] || color === "black") continue; - const vertical = Math.random() > 0.5; - const dx = Math.random() > 0.5 ? 1 : -1; - const dy = Math.random() > 0.5 ? 1 : -1; - - gameState.flashes.push({ - type: "particle", - duration: 250, - ethereal: true, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color, - x: brickCenterX(index) + (dx * gameState.brickWidth) / 2, - y: brickCenterY(index) + (dy * gameState.brickWidth) / 2, - vx: vertical ? 0 : -dx * gameState.baseSpeed, - vy: vertical ? -dy * gameState.baseSpeed : 0, - }); - } - } - - const borderHitCode = bordersHitCheck(ball, gameState.ballSize / 2, delta); - 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); - } - sounds.wallBeep(ball.x); - ball.bouncesList?.push({x: ball.previousX, y: ball.previousY}); - } - - // 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, - ); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - sounds.wallBeep(ball.x); - } else { - ball.vy *= -1; - gameState.perks.extra_life = Math.max(0, gameState.perks.extra_life - 1); - sounds.lifeLost(ball.x); - if (!isOptionOn("basic")) { - for (let i = 0; i < 10; i++) - gameState.flashes.push({ - type: "particle", - ethereal: false, - color: "red", - destroyed: false, - duration: 150, - size: gameState.coinSize / 2, - time: gameState.levelTime, - x: ball.x, - y: ball.y, - vx: Math.random() * gameState.baseSpeed * 3, - vy: gameState.baseSpeed * 3, - }); - } - } - if (gameState.perks.streak_shots) { - resetCombo(gameState, ball.x, ball.y); - } - - if (gameState.perks.respawn) { - ball.hitItem - .slice(0, -1) - .slice(0, gameState.perks.respawn) - .forEach(({index, color}) => { - if (!gameState.bricks[index] && color !== "black") - gameState.bricks[index] = color; - }); - } - ball.hitItem = []; - if (!ball.hitSinceBounce) { - gameState.runStatistics.misses++; - gameState.levelMisses++; - resetCombo(gameState, ball.x, ball.y); - gameState.flashes.push({ - type: "text", - text: t('play.missed_ball'), - duration: 500, - time: gameState.levelTime, - size: gameState.puckHeight * 1.5, - color: "red", - x: gameState.puckPosition, - y: gameState.gameZoneHeight - gameState.puckHeight * 2, - }); - } - gameState.runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.sapperUses = 0; - ball.piercedSinceBounce = 0; - ball.bouncesList = [ - { - x: ball.previousX, - y: ball.previousY, - }, - ]; - } - - if ( - ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 && - gameState.running - ) { - 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; - let sturdyBounce = - hitBrick && - gameState.bricks[hitBrick] !== "black" && - gameState.perks.sturdy_bricks && - gameState.perks.sturdy_bricks > Math.random() * 5; - - let pierce = false; - if (sturdyBounce || typeof hitBrick === "undefined") { - // cannot pierce - } else if (shouldPierceByColor(vhit, hhit, chit)) { - pierce = true; - } else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { - pierce = true; - ball.piercedSinceBounce++; - } - - 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 (sturdyBounce) { - sounds.wallBeep(x); - return; - } - if (typeof hitBrick !== "undefined") { - const initialBrickColor = gameState.bricks[hitBrick]; - - explodeBrick(hitBrick, ball, false); - - if ( - ball.sapperUses < gameState.perks.sapper && - initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !gameState.bricks[hitBrick] - ) { - gameState.bricks[hitBrick] = "black"; - ball.sapperUses++; - } - } - - if (!isOptionOn("basic")) { - ball.sparks += (delta * (gameState.combo - 1)) / 30; - if (ball.sparks > 1) { - gameState.flashes.push({ - type: "particle", - duration: 100 * ball.sparks, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: gameState.ballsColor, - x: ball.x, - y: ball.y, - vx: (Math.random() - 0.5) * gameState.baseSpeed, - vy: (Math.random() - 0.5) * gameState.baseSpeed, - ethereal: false, - }); - ball.sparks = 0; - } - } -} - -export function getTotalScore() { - try { - return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); - } catch (e) { - return 0; - } -} - -export function addToTotalScore(points: number) { - if (gameState.isCreativeModeRun) return; - try { - localStorage.setItem( - "breakout_71_total_score", - JSON.stringify(getTotalScore() + points), - ); - } catch (e) { - } -} - -export function addToTotalPlayTime(ms: number) { - try { - localStorage.setItem( - "breakout_71_total_play_time", - JSON.stringify( - JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + - ms, - ), - ); - } catch (e) { - } -} - -export function gameOver(title: string, intro: string) { - if (!gameState.running) return; - pause(true); - stopRecording(); - addToTotalPlayTime(gameState.runStatistics.runTime); - gameState.runStatistics.max_level = gameState.currentLevel + 1; - - let animationDelay = -300; - const getDelay = () => { - animationDelay += 800; - return "animation-delay:" + animationDelay + "ms;"; - }; - // unlocks - let unlocksInfo = ""; - const endTs = getTotalScore(); - const startTs = endTs - gameState.score; - const list = getUpgraderUnlockPoints(); - list - .filter((u) => u.threshold > startTs && u.threshold < endTs) - .forEach((u) => { - unlocksInfo += ` -

- ${u.title} - -

-`; - }); - const previousUnlockAt = - findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; - const nextUnlock = list.find((u) => u.threshold > endTs); - - if (nextUnlock) { - const total = nextUnlock?.threshold - previousUnlockAt; - const done = endTs - previousUnlockAt; - - intro += t('gameOver.next_unlock', {points: nextUnlock.threshold - endTs}); - - const scaleX = (done / total).toFixed(2); - unlocksInfo += ` -

- ${nextUnlock.title} - -

- -`; - list - .slice(list.indexOf(nextUnlock) + 1) - .slice(0, 3) - .forEach((u) => { - unlocksInfo += ` -

- ${u.title} -

-`; - }); - } - - let unlockedItems = list.filter( - (u) => u.threshold > startTs && u.threshold < endTs, - ); - if (unlockedItems.length) { - - unlocksInfo += `

${t('gameOver.unlocked_count', {count: unlockedItems.length})} ${unlockedItems.map((u) => u.title).join(", ")}

`; - } - - // Avoid the sad sound right as we restart a new games - gameState.combo = 1; - - asyncAlert({ - allowClose: true, - title, - text: ` - ${gameState.isCreativeModeRun ? `

${t('gameOver.test_run')}

` : ""} -

${intro}

-

${t('gameOver.cumulative_total', {startTs, endTs})}

- ${unlocksInfo} - `, - actions: [ - { - value: null, - text: t('gameOver.restart'), - help: "", - }, - ], - textAfterButtons: `
- ${getHistograms()} - `, - }).then(() => restart({levelToAvoid: currentLevelInfo().name})); -} - -export function getHistograms() { - let runStats = ""; - try { - // Stores only top 100 runs - let runsHistory = JSON.parse( - localStorage.getItem("breakout_71_runs_history") || "[]", - ) as RunHistoryItem[]; - runsHistory.sort((a, b) => a.score - b.score).reverse(); - runsHistory = runsHistory.slice(0, 100); - - runsHistory.push({ - ...gameState.runStatistics, - perks: gameState.perks, - appVersion, - }); - - // Generate some histogram - if (!gameState.isCreativeModeRun) - localStorage.setItem( - "breakout_71_runs_history", - JSON.stringify(runsHistory, null, 2), - ); - - const makeHistogram = ( - title: string, - getter: (hi: RunHistoryItem) => number, - unit: string, - ) => { - let values = runsHistory.map((h) => getter(h) || 0); - let min = Math.min(...values); - let max = Math.max(...values); - // No point - if (min === max) return ""; - if (max - min < 10) { - // This is mostly useful for levels - min = Math.max(0, max - 10); - max = Math.max(max, min + 10); - } - // One bin per unique value, max 10 - const binsCount = Math.min(values.length, 10); - if (binsCount < 3) return ""; - const bins = [] as number[]; - const binsTotal = [] as number[]; - for (let i = 0; i < binsCount; i++) { - bins.push(0); - binsTotal.push(0); - } - const binSize = (max - min) / bins.length; - const binIndexOf = (v: number) => - Math.min(bins.length - 1, Math.floor((v - min) / binSize)); - values.forEach((v) => { - if (isNaN(v)) return; - const index = binIndexOf(v); - bins[index]++; - binsTotal[index] += v; - }); - if (bins.filter((b) => b).length < 3) return ""; - const maxBin = Math.max(...bins); - const lastValue = values[values.length - 1]; - const activeBin = binIndexOf(lastValue); - - const bars = bins - .map((v, vi) => { - const style = `height: ${(v / maxBin) * 80}px`; - return `${(!v && " ") || (vi == activeBin && lastValue + unit) || Math.round(binsTotal[vi] / v) + unit}`; - }) - .join(""); - - return `

${title} : ${lastValue}${unit}

-
${bars}
- `; - }; - - runStats += makeHistogram(t('gameOver.stats.total_score'), (r) => r.score, ""); - runStats += makeHistogram(t('gameOver.stats.catch_rate'), - (r) => Math.round((r.score / r.coins_spawned) * 100), - "%", - ); - runStats += makeHistogram(t('gameOver.stats.bricks_broken'), (r) => r.bricks_broken, ""); - runStats += makeHistogram( - t('gameOver.stats.bricks_per_minute'), - (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), - "", - ); - runStats += makeHistogram( - t('gameOver.stats.hit_rate'), - (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), - "%", - ); - runStats += makeHistogram( - t('gameOver.stats.duration_per_level'), - (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), - "s", - ); - runStats += makeHistogram(t('gameOver.stats.level_reached'), (r) => r.levelsPlayed, ""); - runStats += makeHistogram(t('gameOver.stats.upgrades_applied'), (r) => r.upgrades_picked, ""); - runStats += makeHistogram(t('gameOver.stats.balls_lost'), (r) => r.balls_lost, ""); - runStats += makeHistogram( - t('gameOver.stats.combo_avg'), - (r) => Math.round(r.coins_spawned / r.bricks_broken), - "", - ); - runStats += makeHistogram(t('gameOver.stats.combo_max'), (r) => r.max_combo, ""); - - if (runStats) { - runStats = - `

${t('gameOver.stats.intro', {count: runsHistory.length - 1})}

` + - runStats; - } - } catch (e) { - console.warn(e); - } - return runStats; -} - -export function explodeBrick(index: number, ball: Ball, isExplosion: boolean) { - const color = gameState.bricks[index]; - if (!color) return; - - if (color === "black") { - delete gameState.bricks[index]; - const x = brickCenterX(index), - y = brickCenterY(index); - - sounds.explode(ball.x); - - const col = index % gameState.gridSize; - const row = Math.floor(index / gameState.gridSize); - const size = 1 + gameState.perks.bigger_explosions; - // Break bricks around - for (let dx = -size; dx <= size; dx++) { - for (let dy = -size; dy <= size; dy++) { - const i = getRowColIndex(row + dy, col + dx); - if (gameState.bricks[i] && i !== -1) { - // Study bricks resist explosions too - if ( - gameState.bricks[i] !== "black" && - gameState.perks.sturdy_bricks > Math.random() * 5 - ) - continue; - explodeBrick(i, ball, true); - } - } - } - - // Blow nearby coins - gameState.coins.forEach((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; - c.vy += ((dy / d2) * 10 * size) / c.weight; - }); - gameState.lastExplosion = Date.now(); - - gameState.flashes.push({ - type: "ball", - duration: 150, - time: gameState.levelTime, - size: gameState.brickWidth * 2, - color: "white", - x, - y, - }); - spawnExplosion( - 7 * (1 + gameState.perks.bigger_explosions), - x, - y, - "white", - 150, - gameState.coinSize, - ); - ball.hitSinceBounce++; - gameState.runStatistics.bricks_broken++; - } else if (color) { - // Even if it bounces we don't want to count that as a miss - ball.hitSinceBounce++; - - // Flashing is take care of by the tick loop - const x = brickCenterX(index), - y = brickCenterY(index); - - gameState.bricks[index] = ""; - - // coins = coins.filter((c) => !c.destroyed); - 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 = gameState.MAX_COINS * (isOptionOn("basic") ? 0.5 : 1); - const spawnableCoins = - gameState.coins.length > gameState.MAX_COINS - ? 1 - : Math.floor(maxCoins - gameState.coins.length) / 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); - - gameState.coins.push({ - points, - size: gameState.coinSize, //-Math.floor(Math.log2(points)), - color: gameState.perks.metamorphosis ? color : "gold", - x: cx, - y: cy, - previousX: cx, - previousY: cy, - // Use previous speed because the ball has already bounced - vx: ball.previousVX * (0.5 + Math.random()), - vy: ball.previousVY * (0.5 + Math.random()), - sx: 0, - sy: 0, - a: Math.random() * Math.PI * 2, - sa: Math.random() - 0.5, - weight: 0.8 + Math.random() * 0.2, - }); - } - - gameState.combo += Math.max( - 0, - 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 - - Math.round(Math.random() * gameState.perks.soft_reset), - ); - - 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); - } - sounds.colorChange(ball.x, 0.8); - gameState.lastExplosion = gameState.levelTime; - gameState.ballsColor = color; - if (!isOptionOn("basic")) { - gameState.balls.forEach((ball) => { - spawnExplosion(7, ball.previousX, ball.previousY, color, 150, 15); - }); - } - } else { - sounds.comboIncreaseMaybe(gameState.combo, ball.x, 1); - } - } - - gameState.flashes.push({ - type: "ball", - duration: 40, - time: gameState.levelTime, - size: gameState.brickWidth, - color: color, - x, - y, - }); - spawnExplosion( - 5 + Math.min(gameState.combo, 30), - x, - y, - color, - 150, - gameState.coinSize / 2, - ); - } - - if (!gameState.bricks[index] && color !== "black") { - ball.hitItem?.push({ - index, - color, - }); - } -} - -export function max_levels() { - return 7 + gameState.perks.extra_levels; -} - -export function render() { - if (gameState.running) gameState.needsRender = true; - if (!gameState.needsRender) { - return; - } - gameState.needsRender = false; - - const level = currentLevelInfo(); - const {width, height} = gameCanvas; - if (!width || !height) return; - - if (gameState.currentLevel || gameState.levelTime) { - menuLabel.innerText = t('play.current_lvl', { - level: gameState.currentLevel + 1, - max: max_levels() - }); - } else { - menuLabel.innerText = t('play.menu_label') - } - scoreDisplay.innerText = `$${gameState.score}`; - - 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; - gameState.coins.forEach((coin) => { - if (!coin.destroyed) - 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(index), - y = brickCenterY(index); - drawFuzzyBall( - ctx, - color == "black" ? "#666" : color, - gameState.brickWidth, - x, - y, - ); - }); - ctx.globalAlpha = 1; - gameState.flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "ball") { - drawFuzzyBall(ctx, color, size, x, y); - } - if (type === "particle") { - 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); - 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); - - gameState.flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "particle") { - drawBall(ctx, color, size, x, y); - } - }); - } - - 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.perks.bigger_explosions && !isOptionOn('basic')) { - if (shaked) { - gameCanvas.style.filter = 'brightness(' + (1 + 100 / (1 + lastExplosionDelay)) + ')'; - } else { - gameCanvas.style.filter = '' - } - } - // Coins - ctx.globalAlpha = 1; - - gameState.coins.forEach((coin) => { - if (!coin.destroyed) { - ctx.globalCompositeOperation = - coin.color === "gold" || level.color ? "source-over" : "screen"; - drawCoin( - ctx, - coin.color, - coin.size, - coin.x, - coin.y, - level.color || "black", - coin.a, - ); - } - }); - - // Black shadow around balls - if (!isOptionOn("basic")) { - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 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"; - gameState.flashes = gameState.flashes.filter( - (f) => gameState.levelTime - f.time < f.duration && !f.destroyed, - ); - - gameState.flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); - if (type === "text") { - ctx.globalCompositeOperation = "source-over"; - drawText(ctx, flash.text, color, size, x, y - elapsed / 10); - } else if (type === "particle") { - 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.offsetXRoundedDown, - gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, - gameState.gameZoneWidthRoundedUp, - 1, - ); - } - } - - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - gameState.balls.forEach((ball) => { - // The white border around is to distinguish colored balls from coins/bg - drawBall( - ctx, - gameState.ballsColor, - gameState.ballSize, - ball.x, - ball.y, - gameState.puckColor, - ); - - if (isTelekinesisActive(ball)) { - ctx.strokeStyle = gameState.puckColor; - ctx.beginPath(); - ctx.bezierCurveTo( - gameState.puckPosition, - gameState.gameZoneHeight, - gameState.puckPosition, - ball.y, - ball.x, - ball.y, - ); - ctx.stroke(); - } - }); - // The puck - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) { - drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2); - } - drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight); - - 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, - comboText, - "#FFF", - gameState.puckHeight, - gameState.puckPosition, - gameState.gameZoneHeight - gameState.puckHeight / 2, - false, - ); - } - } - // Borders - const hasCombo = gameState.combo > baseCombo(gameState); - ctx.globalCompositeOperation = "source-over"; - 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; - ctx.fillRect(gameState.offsetX - 1, 0, 1, height); - ctx.fillStyle = - hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; - ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); - } else { - ctx.fillStyle = "red"; - if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); - if (hasCombo && gameState.perks.right_is_lava) - ctx.fillRect(width - 1, 0, 1, height); - } - - if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) { - ctx.fillStyle = "red"; - ctx.fillRect( - gameState.offsetXRoundedDown, - 0, - gameState.gameZoneWidthRoundedUp, - 1, - ); - } - const redBottom = - gameState.perks.compound_interest && gameState.combo > baseCombo(gameState); - ctx.fillStyle = redBottom ? "red" : gameState.puckColor; - if (isOptionOn("mobile-mode")) { - ctx.fillRect( - gameState.offsetXRoundedDown, - gameState.gameZoneHeight, - gameState.gameZoneWidthRoundedUp, - 1, - ); - if (!gameState.running) { - drawText( - ctx, - t('play.mobile_press_to_play'), - gameState.puckColor, - gameState.puckHeight, - gameState.canvasWidth / 2, - gameState.gameZoneHeight + - (gameState.canvasHeight - gameState.gameZoneHeight) / 2, - ); - } - } else if (redBottom) { - ctx.fillRect( - gameState.offsetXRoundedDown, - gameState.gameZoneHeight - 1, - gameState.gameZoneWidthRoundedUp, - 1, - ); - } - - if (shaked) { - ctx.resetTransform(); - } - - recordOneFrame(); -} - -let cachedBricksRender = document.createElement("canvas"); -let cachedBricksRenderKey = ""; - -export function renderAllBricks() { - ctx.globalAlpha = 1; - - const redBorderOnBricksWithWrongColor = - gameState.combo > baseCombo(gameState) && gameState.perks.picky_eater && !isOptionOn('basic'); - - const newKey = - gameState.gameZoneWidth + - "_" + - gameState.bricks.join("_") + - bombSVG.complete + - "_" + - redBorderOnBricksWithWrongColor + - "_" + - gameState.ballsColor + - "_" + - gameState.perks.pierce_color; - 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(index), - y = brickCenterY(index); - - if (!color) return; - - const borderColor = - (gameState.ballsColor !== color && - color !== "black" && - redBorderOnBricksWithWrongColor && - "red") || - color; - - drawBrick(canctx, color, borderColor, x, y); - if (color === "black") { - canctx.globalCompositeOperation = "source-over"; - drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); - } - }); - } - - ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); -} - -let cachedGraphics: { [k: string]: HTMLCanvasElement } = {}; - -export function drawPuck( - ctx: CanvasRenderingContext2D, - color: colorString, - puckWidth: number, - puckHeight: number, - yOffset = 0, -) { - const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; - - 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.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(); - 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 = "", -) { - 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 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; - } - 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, -) { - 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; - - 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(); - - if (color === "gold") { - canctx.strokeStyle = borderColor; - canctx.stroke(); - - 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, -) { - 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), - ); -} - -export function drawBrick( - ctx: CanvasRenderingContext2D, - color: colorString, - borderColor: colorString, - x: number, - y: number, -) { - 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 + "_" + borderColor + "_" + width + "_" + height; - - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = width; - can.height = height; - const bord = 2; - const cornerRadius = 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - - canctx.fillStyle = color; - canctx.strokeStyle = borderColor; - canctx.lineJoin = "round"; - canctx.lineWidth = bord; - roundRect( - canctx, - bord / 2, - bord / 2, - width - bord, - height - bord, - cornerRadius, - ); - canctx.fill(); - 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.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, -) { - const key = "svg" + img + "_" + size + "_" + img.complete; - - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; - - 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); - - 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, -) { - 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"; - - 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), - ); } window.addEventListener("visibilitychange", () => { @@ -2332,128 +414,6 @@ window.addEventListener("visibilitychange", () => { } }); -const scoreDisplay = document.getElementById("score") as HTMLButtonElement; -const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement; -let alertsOpen = 0, - closeModal: null | (() => void) = null; - -type AsyncAlertAction = { - text?: string; - value?: t; - help?: string; - disabled?: boolean; - icon?: string; - className?: string; -}; - -export function asyncAlert({ - title, - text, - actions, - allowClose = true, - textAfterButtons = "", - actionsAsGrid = false, - }: { - title?: string; - text?: string; - actions?: AsyncAlertAction[]; - textAfterButtons?: string; - allowClose?: boolean; - actionsAsGrid?: boolean; -}): Promise { - alertsOpen++; - return new Promise((resolve) => { - const popupWrap = document.createElement("div"); - document.body.appendChild(popupWrap); - popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); - - function closeWithResult(value: t | undefined) { - resolve(value); - // Doing this async lets the menu scroll persist if it's shown a second time - setTimeout(() => { - document.body.removeChild(popupWrap); - }); - } - - if (allowClose) { - const closeButton = document.createElement("button"); - closeButton.title = t('play.close_modale_window_tooltip'); - closeButton.className = "close-modale"; - closeButton.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(undefined); - }); - closeModal = () => { - closeWithResult(undefined); - }; - popupWrap.appendChild(closeButton); - } - - const popup = document.createElement("div"); - - if (title) { - const p = document.createElement("h2"); - p.innerHTML = title; - popup.appendChild(p); - } - - if (text) { - const p = document.createElement("div"); - p.innerHTML = text; - popup.appendChild(p); - } - - const buttons = document.createElement("section"); - popup.appendChild(buttons); - - actions - ?.filter((i) => i) - .forEach(({text, value, help, disabled, className = "", icon = ""}) => { - const button = document.createElement("button"); - - button.innerHTML = ` -${icon} -
- ${text} - ${help || ""} -
`; - - if (disabled) { - button.setAttribute("disabled", "disabled"); - } else { - button.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(value); - }); - } - button.className = className; - buttons.appendChild(button); - }); - - if (textAfterButtons) { - const p = document.createElement("div"); - p.className = "textAfterButtons"; - p.innerHTML = textAfterButtons; - popup.appendChild(p); - } - - popupWrap.appendChild(popup); - ( - popup.querySelector("button:not([disabled])") as HTMLButtonElement - )?.focus(); - }).then( - (v: unknown) => { - alertsOpen--; - closeModal = null; - return v as t | undefined; - }, - () => { - closeModal = null; - alertsOpen--; - }, - ); -} - scoreDisplay.addEventListener("click", (e) => { e.preventDefault(); openScorePanel(); @@ -2469,12 +429,12 @@ async function openScorePanel() { pause(true); const cb = await asyncAlert({ title: t('score_panel.title', { - score: gameState.score, level: gameState.currentLevel + 1, max: max_levels() + score: gameState.score, level: gameState.currentLevel + 1, max: max_levels(gameState) }), text: ` ${gameState.isCreativeModeRun ? "

${t('score_panel.test_run}

" : ""}

${t('score_panel.upgrades_picked')}

-

${pickedUpgradesHTMl()}

+

${pickedUpgradesHTMl(gameState)}

`, allowClose: true, actions: [ @@ -2488,7 +448,7 @@ async function openScorePanel() { text: t('score_panel.restart'), help: t('score_panel.restart_help'), value: () => { - restart({levelToAvoid: currentLevelInfo().name}); + restart({levelToAvoid: currentLevelInfo(gameState).name}); }, }, ], @@ -2533,6 +493,8 @@ async function openSettingsPanel() { help: options[key].help, value: () => { toggleOption(key); + if (key === "mobile-mode") fitSize() + openSettingsPanel(); }, }); @@ -2755,304 +717,6 @@ export async function confirmRestart() { } -export function distance2( - a: { x: number; y: number }, - b: { x: number; y: number }, -) { - return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); -} - -export function distanceBetween( - a: { x: number; y: number }, - b: { x: number; y: number }, -) { - return Math.sqrt(distance2(a, b)); -} - -export function rainbowColor(): colorString { - return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; -} - -export function repulse( - a: Ball, - b: BallLike, - power: number, - impactsBToo: boolean, -) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameState.gameZoneWidth / 2; - 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; - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, - }); - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, - }); - } -} - -export function attract(a: Ball, b: Ball, power: number) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = gameState.gameZoneWidth * 0.5; - 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 speed = 10; - const rand = 2; - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, - }); - gameState.flashes.push({ - type: "particle", - duration: 100, - time: gameState.levelTime, - size: gameState.coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, - }); -} - -let mediaRecorder: MediaRecorder | null, - captureStream: MediaStream, - captureTrack: CanvasCaptureMediaStreamTrack, - recordCanvas: HTMLCanvasElement, - recordCanvasCtx: CanvasRenderingContext2D; - -export function recordOneFrame() { - if (!isOptionOn("record")) { - return; - } - if (!gameState.running) return; - if (!captureStream) return; - drawMainCanvasOnSmallCanvas(); - if (captureTrack?.requestFrame) { - captureTrack?.requestFrame(); - } else if (captureStream?.requestFrame) { - captureStream.requestFrame(); - } -} - -export function drawMainCanvasOnSmallCanvas() { - if (!recordCanvasCtx) return; - recordCanvasCtx.drawImage( - gameCanvas, - gameState.offsetXRoundedDown, - 0, - gameState.gameZoneWidthRoundedUp, - gameState.gameZoneHeight, - 0, - 0, - recordCanvas.width, - recordCanvas.height, - ); - - // Here we don't use drawText as we don't want to cache a picture for each distinct value of score - recordCanvasCtx.fillStyle = "#FFF"; - recordCanvasCtx.textBaseline = "top"; - recordCanvasCtx.font = "12px monospace"; - recordCanvasCtx.textAlign = "right"; - recordCanvasCtx.fillText( - gameState.score.toString(), - recordCanvas.width - 12, - 12, - ); - - recordCanvasCtx.textAlign = "left"; - recordCanvasCtx.fillText( - "Level " + (gameState.currentLevel + 1) + "/" + max_levels(), - 12, - 12, - ); -} - -export function startRecordingGame() { - if (!isOptionOn("record")) { - return; - } - if (mediaRecorder) return; - if (!recordCanvas) { - // Smaller canvas with fewer details - recordCanvas = document.createElement("canvas"); - recordCanvasCtx = recordCanvas.getContext("2d", { - antialias: false, - alpha: false, - }) as CanvasRenderingContext2D; - - captureStream = recordCanvas.captureStream(0); - captureTrack = - captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; - - const track = getAudioRecordingTrack(); - if (track) { - captureStream.addTrack(track.stream.getAudioTracks()[0]); - } - } - - recordCanvas.width = gameState.gameZoneWidthRoundedUp; - recordCanvas.height = gameState.gameZoneHeight; - - // drawMainCanvasOnSmallCanvas() - const recordedChunks: Blob[] = []; - - const instance = new MediaRecorder(captureStream, { - videoBitsPerSecond: 3500000, - }); - mediaRecorder = instance; - instance.start(); - mediaRecorder.pause(); - instance.ondataavailable = function (event) { - recordedChunks.push(event.data); - }; - - instance.onstop = async function () { - let targetDiv: HTMLElement | null; - let blob = new Blob(recordedChunks, {type: "video/webm"}); - if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short - - while ( - !(targetDiv = document.getElementById("level-recording-container")) - ) { - await new Promise((r) => setTimeout(r, 200)); - } - const video = document.createElement("video"); - video.autoplay = true; - video.controls = false; - video.disablePictureInPicture = true; - video.disableRemotePlayback = true; - video.width = recordCanvas.width; - video.height = recordCanvas.height; - video.loop = true; - video.muted = true; - video.playsInline = true; - video.src = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.download = captureFileName("webm"); - a.target = "_blank"; - a.href = video.src; - a.textContent = t('main_menu.record_download', { - size: (blob.size / 1000000).toFixed(2) - }); - targetDiv.appendChild(video); - targetDiv.appendChild(a); - }; -} - -export function pauseRecording() { - if (!isOptionOn("record")) { - return; - } - if (mediaRecorder?.state === "recording") { - mediaRecorder?.pause(); - } -} - -export function resumeRecording() { - if (!isOptionOn("record")) { - return; - } - if (mediaRecorder?.state === "paused") { - mediaRecorder.resume(); - } -} - -export function stopRecording() { - if (!isOptionOn("record")) { - return; - } - if (!mediaRecorder) return; - mediaRecorder?.stop(); - mediaRecorder = null; -} - -export function captureFileName(ext = "webm") { - return ( - "breakout-71-capture-" + - new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + - "." + - ext - ); -} - -export function findLast( - arr: T[], - predicate: (item: T, index: number, array: T[]) => boolean, -) { - let i = arr.length; - while (--i) - if (predicate(arr[i], i, arr)) { - return arr[i]; - } -} - export function toggleFullScreen() { try { if (document.fullscreenElement !== null) { @@ -3131,7 +795,7 @@ document.addEventListener("keyup", async (e) => { openScorePanel().then(); } else if (e.key.toLowerCase() === "r" && !alertsOpen) { if (await confirmRestart()) { - restart({levelToAvoid: currentLevelInfo().name}); + restart({levelToAvoid: currentLevelInfo(gameState).name}); } } else { return; @@ -3139,111 +803,12 @@ document.addEventListener("keyup", async (e) => { e.preventDefault(); }); -export function newGameState(params: RunParams): GameState { - const totalScoreAtRunStart = getTotalScore(); - const firstLevel = params?.level - ? allLevels.filter((l) => l.name === params?.level) - : []; - - const restInRandomOrder = allLevels - .filter((l) => totalScoreAtRunStart >= l.threshold) - .filter((l) => l.name !== params?.level) - .filter((l) => l.name !== params?.levelToAvoid) - .sort(() => Math.random() - 0.5); - - const runLevels = firstLevel.concat( - restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), - ); - - const perks = {...makeEmptyPerksMap(upgrades), ...(params?.perks || {})}; - - const gameState: GameState = { - runLevels, - currentLevel: 0, - perks, - puckWidth: 200, - baseSpeed: 12, - combo: 1, - gridSize: 12, - running: false, - puckPosition: 400, - pauseTimeout: null, - canvasWidth: 0, - canvasHeight: 0, - offsetX: 0, - offsetXRoundedDown: 0, - gameZoneWidth: 0, - gameZoneWidthRoundedUp: 0, - gameZoneHeight: 0, - brickWidth: 0, - needsRender: true, - score: 0, - lastScoreIncrease: -1000, - lastExplosion: -1000, - highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"), - balls: [], - ballsColor: "white", - bricks: [], - flashes: [], - coins: [], - levelStartScore: 0, - levelMisses: 0, - levelSpawnedCoins: 0, - lastPlayedCoinGrab: 0, - MAX_COINS: 400, - MAX_PARTICLES: 600, - puckColor: "#FFF", - ballSize: 20, - coinSize: 14, - puckHeight: 20, - totalScoreAtRunStart, - isCreativeModeRun: sumOfKeys(perks) > 1, - pauseUsesDuringRun: 0, - keyboardPuckSpeed: 0, - lastTick: performance.now(), - lastTickDown: 0, - runStatistics: { - started: Date.now(), - levelsPlayed: 0, - runTime: 0, - coins_spawned: 0, - score: 0, - bricks_broken: 0, - misses: 0, - balls_lost: 0, - puck_bounces: 0, - upgrades_picked: 1, - max_combo: 1, - max_level: 0, - }, - lastOffered: {}, - levelTime: 0, - autoCleanUses: 0, - }; - resetBalls(gameState); - - if (!sumOfKeys(gameState.perks)) { - const giftable = getPossibleUpgrades(gameState).filter((u) => u.giftable); - const randomGift = - (isOptionOn("easy") && "slow_down") || - giftable[Math.floor(Math.random() * giftable.length)].id; - perks[randomGift] = 1; - dontOfferTooSoon(gameState, randomGift); - } - for (let perk of upgrades) { - if (gameState.perks[perk.id]) { - dontOfferTooSoon(gameState, perk.id); - } - } - return gameState; -} - export const gameState = newGameState({}); export function restart(params: RunParams) { Object.assign(gameState, newGameState(params)); pauseRecording(); - setLevel(0); + setLevel(gameState, 0); } restart({}); diff --git a/src/gameOver.ts b/src/gameOver.ts new file mode 100644 index 0000000..be478d2 --- /dev/null +++ b/src/gameOver.ts @@ -0,0 +1,251 @@ +import {allLevels, appVersion, upgrades} from "./loadGameData"; +import {t} from "./i18n/i18n"; +import {RunHistoryItem} from "./types"; +import {gameState, pause, restart} from "./game"; +import {currentLevelInfo, findLast} from "./game_utils"; +import {getTotalScore} from "./settings"; +import {stopRecording} from "./recording"; +import {asyncAlert} from "./asyncAlert"; + +export function getUpgraderUnlockPoints() { + let list = [] as { threshold: number; title: string }[]; + + upgrades.forEach((u) => { + if (u.threshold) { + list.push({ + threshold: u.threshold, + title: u.name + ' ' + t('level_up.unlocked_perk'), + }); + } + }); + + allLevels.forEach((l) => { + list.push({ + threshold: l.threshold, + title: l.name + ' ' + t('level_up.unlocked_level'), + }); + }); + + return list + .filter((o) => o.threshold) + .sort((a, b) => a.threshold - b.threshold); +} + +export function addToTotalPlayTime(ms: number) { + try { + localStorage.setItem( + "breakout_71_total_play_time", + JSON.stringify( + JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + + ms, + ), + ); + } catch (e) { + } +} + +export function gameOver(title: string, intro: string) { + if (!gameState.running) return; + pause(true); + stopRecording(); + addToTotalPlayTime(gameState.runStatistics.runTime); + gameState.runStatistics.max_level = gameState.currentLevel + 1; + + let animationDelay = -300; + const getDelay = () => { + animationDelay += 800; + return "animation-delay:" + animationDelay + "ms;"; + }; + // unlocks + let unlocksInfo = ""; + const endTs = getTotalScore(); + const startTs = endTs - gameState.score; + const list = getUpgraderUnlockPoints(); + list + .filter((u) => u.threshold > startTs && u.threshold < endTs) + .forEach((u) => { + unlocksInfo += ` +

+ ${u.title} + +

+`; + }); + const previousUnlockAt = + findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; + const nextUnlock = list.find((u) => u.threshold > endTs); + + if (nextUnlock) { + const total = nextUnlock?.threshold - previousUnlockAt; + const done = endTs - previousUnlockAt; + + intro += t('gameOver.next_unlock', {points: nextUnlock.threshold - endTs}); + + const scaleX = (done / total).toFixed(2); + unlocksInfo += ` +

+ ${nextUnlock.title} + +

+ +`; + list + .slice(list.indexOf(nextUnlock) + 1) + .slice(0, 3) + .forEach((u) => { + unlocksInfo += ` +

+ ${u.title} +

+`; + }); + } + + let unlockedItems = list.filter( + (u) => u.threshold > startTs && u.threshold < endTs, + ); + if (unlockedItems.length) { + + unlocksInfo += `

${t('gameOver.unlocked_count', {count: unlockedItems.length})} ${unlockedItems.map((u) => u.title).join(", ")}

`; + } + + // Avoid the sad sound right as we restart a new games + gameState.combo = 1; + + asyncAlert({ + allowClose: true, + title, + text: ` + ${gameState.isCreativeModeRun ? `

${t('gameOver.test_run')}

` : ""} +

${intro}

+

${t('gameOver.cumulative_total', {startTs, endTs})}

+ ${unlocksInfo} + `, + actions: [ + { + value: null, + text: t('gameOver.restart'), + help: "", + }, + ], + textAfterButtons: `
+ ${getHistograms()} + `, + }).then(() => restart({levelToAvoid: currentLevelInfo(gameState).name})); +} + +export function getHistograms() { + let runStats = ""; + try { + // Stores only top 100 runs + let runsHistory = JSON.parse( + localStorage.getItem("breakout_71_runs_history") || "[]", + ) as RunHistoryItem[]; + runsHistory.sort((a, b) => a.score - b.score).reverse(); + runsHistory = runsHistory.slice(0, 100); + + runsHistory.push({ + ...gameState.runStatistics, + perks: gameState.perks, + appVersion, + }); + + // Generate some histogram + if (!gameState.isCreativeModeRun) + localStorage.setItem( + "breakout_71_runs_history", + JSON.stringify(runsHistory, null, 2), + ); + + const makeHistogram = ( + title: string, + getter: (hi: RunHistoryItem) => number, + unit: string, + ) => { + let values = runsHistory.map((h) => getter(h) || 0); + let min = Math.min(...values); + let max = Math.max(...values); + // No point + if (min === max) return ""; + if (max - min < 10) { + // This is mostly useful for levels + min = Math.max(0, max - 10); + max = Math.max(max, min + 10); + } + // One bin per unique value, max 10 + const binsCount = Math.min(values.length, 10); + if (binsCount < 3) return ""; + const bins = [] as number[]; + const binsTotal = [] as number[]; + for (let i = 0; i < binsCount; i++) { + bins.push(0); + binsTotal.push(0); + } + const binSize = (max - min) / bins.length; + const binIndexOf = (v: number) => + Math.min(bins.length - 1, Math.floor((v - min) / binSize)); + values.forEach((v) => { + if (isNaN(v)) return; + const index = binIndexOf(v); + bins[index]++; + binsTotal[index] += v; + }); + if (bins.filter((b) => b).length < 3) return ""; + const maxBin = Math.max(...bins); + const lastValue = values[values.length - 1]; + const activeBin = binIndexOf(lastValue); + + const bars = bins + .map((v, vi) => { + const style = `height: ${(v / maxBin) * 80}px`; + return `${(!v && " ") || (vi == activeBin && lastValue + unit) || Math.round(binsTotal[vi] / v) + unit}`; + }) + .join(""); + + return `

${title} : ${lastValue}${unit}

+
${bars}
+ `; + }; + + runStats += makeHistogram(t('gameOver.stats.total_score'), (r) => r.score, ""); + runStats += makeHistogram(t('gameOver.stats.catch_rate'), + (r) => Math.round((r.score / r.coins_spawned) * 100), + "%", + ); + runStats += makeHistogram(t('gameOver.stats.bricks_broken'), (r) => r.bricks_broken, ""); + runStats += makeHistogram( + t('gameOver.stats.bricks_per_minute'), + (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), + "", + ); + runStats += makeHistogram( + t('gameOver.stats.hit_rate'), + (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), + "%", + ); + runStats += makeHistogram( + t('gameOver.stats.duration_per_level'), + (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), + "s", + ); + runStats += makeHistogram(t('gameOver.stats.level_reached'), (r) => r.levelsPlayed, ""); + runStats += makeHistogram(t('gameOver.stats.upgrades_applied'), (r) => r.upgrades_picked, ""); + runStats += makeHistogram(t('gameOver.stats.balls_lost'), (r) => r.balls_lost, ""); + runStats += makeHistogram( + t('gameOver.stats.combo_avg'), + (r) => Math.round(r.coins_spawned / r.bricks_broken), + "", + ); + runStats += makeHistogram(t('gameOver.stats.combo_max'), (r) => r.max_combo, ""); + + if (runStats) { + runStats = + `

${t('gameOver.stats.intro', {count: runsHistory.length - 1})}

` + + runStats; + } + } catch (e) { + console.warn(e); + } + return runStats; +} \ No newline at end of file diff --git a/src/gameStateMutators.ts b/src/gameStateMutators.ts new file mode 100644 index 0000000..4f20da1 --- /dev/null +++ b/src/gameStateMutators.ts @@ -0,0 +1,1178 @@ +import {Ball, BallLike, Coin, colorString, GameState, PerkId} from "./types"; +import {sounds} from "./sounds"; +import { + brickCenterX, + brickCenterY, + currentLevelInfo, distanceBetween, + getMajorityValue, + getPossibleUpgrades, + getRowColIndex, + isTelekinesisActive, + max_levels +} from "./game_utils"; +import {t} from "./i18n/i18n"; +import {icons} from "./loadGameData"; + +import {addToTotalScore} from "./settings"; +import {background} from "./render"; +import {gameOver} from "./gameOver"; +import { + bordersHitCheck, + brickIndex, + coinBrickHitCheck, + fitSize, + gameState, + hasBrick, + hitsSomething, + openUpgradesPicker, + pause, + shouldPierceByColor +} from "./game"; +import {stopRecording} from "./recording"; +import {isOptionOn} from "./options"; + +export function setMousePos(gameState: GameState, x: number) { + // Sets the puck position, and updates the ball position if they are supposed to follow it + gameState.puckPosition = x; +} + +export function resetBalls(gameState: 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 = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed; + + 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, + + sx: 0, + sy: 0, + sparks: 0, + piercedSinceBounce: 0, + hitSinceBounce: 0, + hitItem: [], + bouncesList: [], + sapperUses: 0, + }); + } +} + +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); + 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.vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed; + ball.previousVX = ball.vx; + ball.vy = -gameState.baseSpeed; + ball.previousVY = ball.vy; + ball.sx = 0; + ball.sy = 0; + ball.hitItem = []; + ball.hitSinceBounce = 0; + ball.piercedSinceBounce = 0; + }); +} + +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 + + gameState.baseSpeed = Math.max( + 3, + gameState.gameZoneWidth / 12 / 10 + + gameState.currentLevel / 3 + + gameState.levelTime / (30 * 1000) - + gameState.perks.slow_down * 2, + ); + + gameState.puckWidth = + (gameState.gameZoneWidth / 12) * + (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); + + + if ( + gameState.puckPosition < + gameState.offsetXRoundedDown + gameState.puckWidth / 2 + ) { + gameState.puckPosition = + gameState.offsetXRoundedDown + gameState.puckWidth / 2; + } + if ( + gameState.puckPosition > + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp - + gameState.puckWidth / 2 + ) { + gameState.puckPosition = + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp - + gameState.puckWidth / 2; + } + + if (!gameState.running && !gameState.levelTime) { + putBallsAtPuck(gameState); + } +} + +export function baseCombo(gameState: GameState) { + return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; +} + +export function resetCombo( + gameState: GameState, + x: number | undefined, + y: number | undefined, +) { + const prev = gameState.combo; + gameState.combo = baseCombo(gameState); + if (!gameState.levelTime) { + gameState.combo += gameState.perks.hot_start * 15; + } + if (prev > gameState.combo && gameState.perks.soft_reset) { + gameState.combo += Math.floor( + (prev - gameState.combo) * (gameState.perks.soft_reset*10)/100, + ); + } + const lost = Math.max(0, prev - gameState.combo); + if (lost) { + for (let i = 0; i < lost && i < 8; i++) { + setTimeout(() => sounds.comboDecrease(), i * 100); + } + if (typeof x !== "undefined" && typeof y !== "undefined") { + gameState.flashes.push({ + type: "text", + text: "-" + lost, + time: gameState.levelTime, + color: "red", + x: x, + y: y, + duration: 150, + size: gameState.puckHeight, + }); + } + } + return lost; +} + +export function decreaseCombo( + 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); + + if (lost) { + sounds.comboDecrease(); + if (typeof x !== "undefined" && typeof y !== "undefined") { + gameState.flashes.push({ + type: "text", + text: "-" + lost, + time: gameState.levelTime, + color: "red", + x: x, + y: y, + duration: 300, + size: gameState.puckHeight, + }); + } + } +} + +export function spawnExplosion( + gameState: GameState, + count: number, + x: number, + y: number, + color: string, + duration = 150, + size = gameState.coinSize, +) { + if (!!isOptionOn("basic")) return; + if (gameState.flashes.length > gameState.MAX_PARTICLES) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + gameState.flashes.push({ + type: "particle", + time: gameState.levelTime, + size, + x: x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + y: y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + vx: (Math.random() - 0.5) * 30, + vy: (Math.random() - 0.5) * 30, + color, + duration, + ethereal: false, + }); + } +} + +export function explodeBrick(gameState: GameState, index: number, ball: Ball, isExplosion: boolean) { + const color = gameState.bricks[index]; + if (!color) return; + + if (color === "black") { + delete gameState.bricks[index]; + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); + + sounds.explode(ball.x); + + const col = index % gameState.gridSize; + const row = Math.floor(index / gameState.gridSize); + const size = 1 + gameState.perks.bigger_explosions; + // 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 + if ( + gameState.bricks[i] !== "black" && + gameState.perks.sturdy_bricks > Math.random() * 5 + ) + continue; + explodeBrick(gameState, i, ball, true); + } + } + } + + // Blow nearby coins + gameState.coins.forEach((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; + c.vy += ((dy / d2) * 10 * size) / c.weight; + }); + gameState.lastExplosion = Date.now(); + + gameState.flashes.push({ + type: "ball", + duration: 150, + time: gameState.levelTime, + size: gameState.brickWidth * 2, + color: "white", + x, + y, + }); + spawnExplosion(gameState, + 7 * (1 + gameState.perks.bigger_explosions), + x, + y, + "white", + 150, + gameState.coinSize, + ); + ball.hitSinceBounce++; + gameState.runStatistics.bricks_broken++; + } else if (color) { + // Even if it bounces we don't want to count that as a miss + ball.hitSinceBounce++; + + // Flashing is take care of by the tick loop + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); + + gameState.bricks[index] = ""; + + // coins = coins.filter((c) => !c.destroyed); + 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 = gameState.MAX_COINS * (isOptionOn("basic") ? 0.5 : 1); + const spawnableCoins = + gameState.coins.length > gameState.MAX_COINS + ? 1 + : Math.floor(maxCoins - gameState.coins.length) / 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); + + gameState.coins.push({ + points, + size: gameState.coinSize, //-Math.floor(Math.log2(points)), + color: gameState.perks.metamorphosis ? color : "gold", + x: cx, + y: cy, + previousX: cx, + previousY: cy, + // Use previous speed because the ball has already bounced + vx: ball.previousVX * (0.5 + Math.random()), + vy: ball.previousVY * (0.5 + Math.random()), + sx: 0, + sy: 0, + a: Math.random() * Math.PI * 2, + sa: Math.random() - 0.5, + weight: 0.8 + Math.random() * 0.2, + }); + } + + gameState.combo += Math.max( + 0, + 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 + ); + + 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); + } + sounds.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, 150, 15); + }); + } + } else { + sounds.comboIncreaseMaybe(gameState.combo, ball.x, 1); + } + } + + gameState.flashes.push({ + type: "ball", + duration: 40, + time: gameState.levelTime, + size: gameState.brickWidth, + color: color, + x, + y, + }); + spawnExplosion(gameState, + 5 + Math.min(gameState.combo, 30), + x, + y, + color, + 150, + gameState.coinSize / 2, + ); + } + + if (!gameState.bricks[index] && color !== "black") { + ball.hitItem?.push({ + index, + color, + }); + } +} + +export function dontOfferTooSoon(gameState: GameState, id: PerkId) { + 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) + .slice(0, count) + .sort((a, b) => (a.id > b.id ? 1 : -1)); + + 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), + })); +} + +export function addToScore(gameState: GameState, coin: Coin) { + coin.destroyed = true; + 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")) { + gameState.flashes.push({ + type: "particle", + duration: 100 + Math.random() * 50, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: coin.color, + x: coin.previousX, + y: coin.previousY, + vx: (gameState.canvasWidth - coin.x) / 100, + vy: -coin.y / 100, + ethereal: true, + }); + } + + if (Date.now() - gameState.lastPlayedCoinGrab > 16) { + gameState.lastPlayedCoinGrab = Date.now(); + sounds.coinCatch(coin.x); + } + gameState.runStatistics.score += coin.points; +} + +export function setLevel(gameState: GameState, l: number) { + stopRecording(); + pause(false); + if (l > 0) { + openUpgradesPicker(gameState); + } + gameState.currentLevel = l; + + gameState.levelTime = 0; + gameState.autoCleanUses = 0; + gameState.lastTickDown = gameState.levelTime; + gameState.levelStartScore = gameState.score; + gameState.levelSpawnedCoins = 0; + gameState.levelMisses = 0; + gameState.runStatistics.levelsPlayed++; + + resetCombo(gameState, undefined, undefined); + resetBalls(gameState); + + const lvl = currentLevelInfo(gameState); + if (lvl.size !== gameState.gridSize) { + gameState.gridSize = lvl.size; + fitSize(); + } + gameState.coins = []; + gameState.bricks = [...lvl.bricks]; + gameState.flashes = []; + + // 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; +} + +export function rainbowColor(): colorString { + return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; +} + +export function repulse(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 / 2; + 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; + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, + }); + } +} + +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 * 0.5; + 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 speed = 10; + const rand = 2; + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + gameState.flashes.push({ + type: "particle", + duration: 100, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, + }); +} + +export function gameStateTick(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.coins = gameState.coins.filter((coin) => !coin.destroyed); + 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, + ); + } + + if ( + remainingBricks <= gameState.perks.skip_last && + !gameState.autoCleanUses + ) { + gameState.bricks.forEach((type, index) => { + if (type) { + explodeBrick(gameState, index, gameState.balls[0], true); + } + }); + gameState.autoCleanUses++; + } + if (!remainingBricks && !gameState.coins.length) { + if (gameState.currentLevel + 1 < max_levels(gameState)) { + setLevel(gameState, gameState.currentLevel + 1); + } else { + gameOver( + t('gameOver.win.title'), + t('gameOver.win.summary', {score: gameState.score}), + ); + } + } else if (gameState.running || gameState.levelTime) { + let playedCoinBounce = false; + const coinRadius = Math.round(gameState.coinSize / 2); + + gameState.coins.forEach((coin) => { + if (coin.destroyed) return; + if (gameState.perks.coin_magnet) { + const attractionX = + ((frames * (gameState.puckPosition - coin.x)) / + (100 + + Math.pow(coin.y - gameState.gameZoneHeight, 2) + + Math.pow(coin.x - gameState.puckPosition, 2))) * + gameState.perks.coin_magnet * + 100; + coin.vx += attractionX; + coin.sa -= attractionX / 10; + } + + const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames; + + 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 + coin.vy += frames * coin.weight * 0.8; + + const speed = Math.abs(coin.sx) + Math.abs(coin.sx); + const hitBorder = bordersHitCheck(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 + gameState.puckHeight + ) { + addToScore(gameState, coin); + } else if (coin.y > gameState.canvasHeight + coinRadius) { + coin.destroyed = true; + if (gameState.perks.compound_interest) { + resetCombo(gameState, coin.x, coin.y); + } + } + + const hitBrick = coinBrickHitCheck(coin); + + if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { + if ( + gameState.bricks[hitBrick] && + coin.color !== gameState.bricks[hitBrick] && + gameState.bricks[hitBrick] !== "black" && + !coin.coloredABrick + ) { + gameState.bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + sounds.colorChange(coin.x, 0.3); + } + } + if (typeof hitBrick !== "undefined" || hitBorder) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20 && !playedCoinBounce) { + playedCoinBounce = true; + sounds.coinBounce(coin.x, 0.2); + } + + if (Math.abs(coin.vy) < 3) { + coin.vy = 0; + } + } + }); + + gameState.balls.forEach((ball) => ballTick(gameState, ball, frames)); + + 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) { + gameState.flashes.push({ + type: "particle", + duration: 150, + ethereal: true, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: rainbowColor(), + x: + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + y: Math.random() * gameState.gameZoneHeight, + vx: windD * 8, + vy: 0, + }); + } + } + } + + gameState.flashes.forEach((flash) => { + if (flash.type === "particle") { + flash.x += flash.vx * frames; + flash.y += flash.vy * frames; + if (!flash.ethereal) { + flash.vy += 0.5; + if (hasBrick(brickIndex(flash.x, flash.y))) { + flash.destroyed = true; + } + } + } + }); + } + + if (gameState.combo > baseCombo(gameState)) { + // The red should still be visible on a white bg + const baseParticle = !isOptionOn("basic") && + (gameState.combo - baseCombo(gameState)) * Math.random() > 5 && + gameState.running && { + type: "particle" as const, + duration: 100 * (Math.random() + 1), + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: "red", + ethereal: true, + }; + + if (gameState.perks.top_is_lava) { + baseParticle && + gameState.flashes.push({ + ...baseParticle, + x: + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + y: 0, + vx: (Math.random() - 0.5) * 10, + vy: 5, + }); + } + + if (gameState.perks.left_is_lava && baseParticle) { + gameState.flashes.push({ + ...baseParticle, + x: gameState.offsetXRoundedDown, + y: Math.random() * gameState.gameZoneHeight, + vx: 5, + vy: (Math.random() - 0.5) * 10, + }); + } + + if (gameState.perks.right_is_lava && baseParticle) { + gameState.flashes.push({ + ...baseParticle, + x: gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, + y: Math.random() * gameState.gameZoneHeight, + vx: -5, + vy: (Math.random() - 0.5) * 10, + }); + } + + 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 + ); + baseParticle && + gameState.flashes.push({ + ...baseParticle, + x, + y: gameState.gameZoneHeight, + vx: (Math.random() - 0.5) * 10, + vy: -5, + }); + } + if (gameState.perks.streak_shots) { + const pos = 0.5 - Math.random(); + baseParticle && + gameState.flashes.push({ + ...baseParticle, + duration: 100, + x: gameState.puckPosition + gameState.puckWidth * pos, + y: gameState.gameZoneHeight - gameState.puckHeight, + vx: pos * 10, + vy: -5, + }); + } + } +} + +export function ballTick(gameState: GameState, ball: Ball, delta: number) { + 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; + if (isTelekinesisActive(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += + ((gameState.puckPosition - ball.x) / 1000) * + delta * + gameState.perks.telekinesis; + } + + 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_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 + + (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, + ); + } + + if ( + gameState.perks.respawn && + ball.hitItem?.length > 1 && + !isOptionOn("basic") + ) { + for ( + let i = 0; + i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; + i++ + ) { + const {index, color} = ball.hitItem[i]; + if (gameState.bricks[index] || color === "black") continue; + const vertical = Math.random() > 0.5; + const dx = Math.random() > 0.5 ? 1 : -1; + const dy = Math.random() > 0.5 ? 1 : -1; + + gameState.flashes.push({ + type: "particle", + duration: 250, + ethereal: true, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color, + x: brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2, + y: brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2, + vx: vertical ? 0 : -dx * gameState.baseSpeed, + vy: vertical ? -dy * gameState.baseSpeed : 0, + }); + } + } + + const borderHitCode = bordersHitCheck(ball, gameState.ballSize / 2, delta); + 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); + } + sounds.wallBeep(ball.x); + ball.bouncesList?.push({x: ball.previousX, y: ball.previousY}); + } + + // 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, + ); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + sounds.wallBeep(ball.x); + } else { + ball.vy *= -1; + gameState.perks.extra_life = Math.max(0, gameState.perks.extra_life - 1); + sounds.lifeLost(ball.x); + if (!isOptionOn("basic")) { + for (let i = 0; i < 10; i++) + gameState.flashes.push({ + type: "particle", + ethereal: false, + color: "red", + destroyed: false, + duration: 150, + size: gameState.coinSize / 2, + time: gameState.levelTime, + x: ball.x, + y: ball.y, + vx: Math.random() * gameState.baseSpeed * 3, + vy: gameState.baseSpeed * 3, + }); + } + } + if (gameState.perks.streak_shots) { + resetCombo(gameState, ball.x, ball.y); + } + + if (gameState.perks.respawn) { + ball.hitItem + .slice(0, -1) + .slice(0, gameState.perks.respawn) + .forEach(({index, color}) => { + if (!gameState.bricks[index] && color !== "black") + gameState.bricks[index] = color; + }); + } + ball.hitItem = []; + if (!ball.hitSinceBounce) { + gameState.runStatistics.misses++; + gameState.levelMisses++; + resetCombo(gameState, ball.x, ball.y); + gameState.flashes.push({ + type: "text", + text: t('play.missed_ball'), + duration: 500, + time: gameState.levelTime, + size: gameState.puckHeight * 1.5, + color: "red", + x: gameState.puckPosition, + y: gameState.gameZoneHeight - gameState.puckHeight * 2, + }); + } + gameState.runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.sapperUses = 0; + ball.piercedSinceBounce = 0; + ball.bouncesList = [ + { + x: ball.previousX, + y: ball.previousY, + }, + ]; + } + + if ( + ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 && + gameState.running + ) { + 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; + let sturdyBounce = + hitBrick && + gameState.bricks[hitBrick] !== "black" && + gameState.perks.sturdy_bricks && + gameState.perks.sturdy_bricks > Math.random() * 5; + + let pierce = false; + if (sturdyBounce || typeof hitBrick === "undefined") { + // cannot pierce + } else if (shouldPierceByColor(vhit, hhit, chit)) { + pierce = true; + } else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { + pierce = true; + ball.piercedSinceBounce++; + } + + 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 (sturdyBounce) { + sounds.wallBeep(x); + return; + } + if (typeof hitBrick !== "undefined") { + const initialBrickColor = gameState.bricks[hitBrick]; + + 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] + ) { + gameState.bricks[hitBrick] = "black"; + ball.sapperUses++; + } + } + + if (!isOptionOn("basic")) { + ball.sparks += (delta * (gameState.combo - 1)) / 30; + if (ball.sparks > 1) { + gameState.flashes.push({ + type: "particle", + duration: 100 * ball.sparks, + time: gameState.levelTime, + size: gameState.coinSize / 2, + color: gameState.ballsColor, + x: ball.x, + y: ball.y, + vx: (Math.random() - 0.5) * gameState.baseSpeed, + vy: (Math.random() - 0.5) * gameState.baseSpeed, + ethereal: false, + }); + ball.sparks = 0; + } + } +} \ No newline at end of file diff --git a/src/game_utils.ts b/src/game_utils.ts index 33af71d..a830cbd 100644 --- a/src/game_utils.ts +++ b/src/game_utils.ts @@ -1,4 +1,5 @@ -import { PerkId, PerksMap, Upgrade } from "./types"; +import {Ball, GameState, PerkId, PerksMap} from "./types"; +import {icons, upgrades} from "./loadGameData"; export function getMajorityValue(arr: string[]): string { const count: { [k: string]: number } = {}; @@ -22,3 +23,79 @@ export const makeEmptyPerksMap = (upgrades: { id: PerkId }[]) => { upgrades.forEach((u) => (p[u.id] = 0)); return p as PerksMap; }; + +export function brickCenterX(gameState: GameState, index: number) { + return ( + gameState.offsetX + + ((index % gameState.gridSize) + 0.5) * gameState.brickWidth + ); +} + +export function brickCenterY(gameState: GameState, index: number) { + return (Math.floor(index / gameState.gridSize) + 0.5) * gameState.brickWidth; +} + +export function getRowColIndex(gameState: GameState, row: number, col: number) { + if ( + row < 0 || + col < 0 || + row >= gameState.gridSize || + col >= gameState.gridSize + ) + return -1; + return row * gameState.gridSize + col; +} + +export function getPossibleUpgrades(gameState: GameState) { + return upgrades + .filter((u) => gameState.totalScoreAtRunStart >= u.threshold) + .filter((u) => !u?.requires || gameState.perks[u?.requires]); +} + +export function max_levels(gameState: GameState) { + return 7 + gameState.perks.extra_levels; +} + +export function pickedUpgradesHTMl(gameState: GameState) { + let list = ""; + for (let u of upgrades) { + for (let i = 0; i < gameState.perks[u.id]; i++) + list += icons["icon:" + u.id] + " "; + } + return list; +} + +export function currentLevelInfo(gameState: GameState) { + return gameState.runLevels[ + gameState.currentLevel % gameState.runLevels.length + ]; +} + +export function isTelekinesisActive(gameState: GameState, ball: Ball) { + return gameState.perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; +} + +export function findLast( + arr: T[], + predicate: (item: T, index: number, array: T[]) => boolean, +) { + let i = arr.length; + while (--i) + if (predicate(arr[i], i, arr)) { + return arr[i]; + } +} + +export function distance2( + a: { x: number; y: number }, + b: { x: number; y: number }, +) { + return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); +} + +export function distanceBetween( + a: { x: number; y: number }, + b: { x: number; y: number }, +) { + return Math.sqrt(distance2(a, b)); +} \ No newline at end of file diff --git a/src/i18n/en.json b/src/i18n/en.json index a1ddf91..851de48 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -96,7 +96,7 @@ "upgrades.ball_repulse_ball.help_plural": "Stronger repulsion force", "upgrades.ball_repulse_ball.name": "Personal space", "upgrades.base_combo.fullHelp": "Your combo (number of coins per bricks) normally starts at 1 at the beginning of the level, and resets to one when you bounce around without hitting anything. With this perk, the combo starts 3 points higher, so you'll always get at least 4 coins per brick. Whenever your combo reset, it will go back to 4 and not 1. Your ball will glitter a bit to indicate that its combo is higher than one.", - "upgrades.base_combo.help": "Every brick drops at least {{coins}} coins.", + "upgrades.base_combo.help": "Combo starts at {{coins}}.", "upgrades.base_combo.name": "+3 base combo", "upgrades.bigger_explosions.fullHelp": "The default explosion clears a 3x3 square, with this it becomes a 5x5 square, and the blow on the coins is also significantly stronger. The screen will flash after each explosion (except in basic mode)", "upgrades.bigger_explosions.help": "Bigger explosions", @@ -125,7 +125,7 @@ "upgrades.instant_upgrade.help": "-1 choice until run end.", "upgrades.instant_upgrade.name": "+2 upgrades now", "upgrades.left_is_lava.fullHelp": "Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\n\nHowever, your combo will reset as soon as your ball hits the left side . \n\nAs soon as your combo rises, the left side becomes red to remind you that you should avoid hitting them. \n\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any of the reset conditions are met.", - "upgrades.left_is_lava.help": "More coins if you don't touch the left side.", + "upgrades.left_is_lava.help": "+1 combo per brick broken, resets on left side hit", "upgrades.left_is_lava.name": "Avoid left side", "upgrades.metamorphosis.fullHelp": "With this perk, coins will be of the color of the brick they come from, and will color the first brick they touch in the same color. Coins spawn with the speed of the ball that broke them, which means you can aim a bit in the direction of the bricks you want to \"paint\".", "upgrades.metamorphosis.help": "Coins stain the bricks they touch", @@ -137,7 +137,7 @@ "upgrades.one_more_choice.help": "Further level ups will offer one more option in the list", "upgrades.one_more_choice.name": "+1 choice until run end", "upgrades.picky_eater.fullHelp": "Whenever you break a brick the same color as your ball, your combo increases by one. \nIf it's a different color, the ball takes that new color, but the combo resets.\nThe bricks with the right color will get a white border. \nOnce you get a combo higher than your minimum, the bricks of the wrong color will get a red halo. \nIf you have more than one ball, they all change color whenever one of them hits a brick.", - "upgrades.picky_eater.help": "More coins if you break bricks color by color.", + "upgrades.picky_eater.help": "+1 combo per brick broken, resets on ball color change", "upgrades.picky_eater.name": "Picky eater", "upgrades.pierce.fullHelp": "The ball normally bounces as soon as it touches something. With this perk, it will continue its trajectory for up to 3 bricks broken. \nAfter that, it will bounce on the 4th brick, and you'll need to touch the puck to reset the counter.", "upgrades.pierce.help": "Ball pierces {{count}} bricks after a puck bounce", @@ -154,15 +154,15 @@ "upgrades.respawn.help_plural": "More bricks can re-spawn", "upgrades.respawn.name": "Re-spawn", "upgrades.right_is_lava.fullHelp": "Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break.\n\nHowever, your combo will reset as soon as your ball hits the right side . \n\nAs soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them\n\nThe effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any\nof the reset conditions are met.", - "upgrades.right_is_lava.help": "More coins if you don't touch the right side.", + "upgrades.right_is_lava.help": "+1 combo per brick broken, resets on right side hit", "upgrades.right_is_lava.name": "Avoid right side", "upgrades.sapper.fullHelp": "Instead of just disappearing, the first brick you break will be replaced by a bomb brick. \n\nBouncing the ball on the puck re-arms the effect. \n\nLeveling-up this perk will allow you to place more bombs.\n\nRemember that bombs impact the velocity of nearby coins, so too many explosions could make it hard to catch the fruits of your hard work.", "upgrades.sapper.help": "The first brick broken becomes a bomb.", "upgrades.sapper.help_plural": "The first {{lvl}} bricks broken become bombs.", "upgrades.sapper.name": "Sapper", "upgrades.skip_last.fullHelp": "You need to break all bricks to go to the next level. However, it can be hard to get the last ones. \n\nClearing a level early brings extra choices when upgrading. Never missing the bricks is also very beneficial. \n\nSo if you find it difficult to break the last bricks, getting this perk a few time can help.", - "upgrades.skip_last.help": "The last brick will self-destruct.", - "upgrades.skip_last.help_plural": "The last {{lvl}} bricks will self-destruct.", + "upgrades.skip_last.help": "The last brick will explode.", + "upgrades.skip_last.help_plural": "The last {{lvl}} bricks will explode.", "upgrades.skip_last.name": "Easy Cleanup", "upgrades.slow_down.fullHelp": "The ball starts relatively slow, but every level of your run it will start a bit faster. \n\nIt will also accelerate if you spend a lot of time in one level. \n\nThis perk makes it more manageable. \n\nYou can get it at the start every time by enabling kid mode in the menu.", "upgrades.slow_down.help": "Ball moves more slowly", @@ -171,8 +171,8 @@ "upgrades.smaller_puck.help": "Also gives +5 base combo", "upgrades.smaller_puck.help_plural": "Even smaller puck and higher base combo", "upgrades.smaller_puck.name": "Smaller puck", - "upgrades.soft_reset.fullHelp": "The combo normally climbs every time you break a brick. This will sometimes cancel that climb, but also limit the impact of a combo reset.", - "upgrades.soft_reset.help": "Combo grows slower but resets less", + "upgrades.soft_reset.fullHelp": "Limit the impact of a combo reset.", + "upgrades.soft_reset.help": "Combo resets keeps {{percent}}%", "upgrades.soft_reset.name": "Soft reset", "upgrades.streak_shots.fullHelp": "Every time you break a brick, your combo (number of coins per bricks) increases by one. \n\nHowever, as soon as the ball touches your puck, the combo is reset to its default value, and you'll just get one coin per brick.\n\nOnce your combo rises above the base value, your puck will become red to remind you that it will destroy your combo to touch it with the ball.\n\nThis can stack with other combo related perks, the combo will rise faster but reset more easily as any of the conditions is enough to reset it. ", "upgrades.streak_shots.help": "More coins if you break many bricks at once.", diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 5ca163e..5a5a6e3 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -96,7 +96,7 @@ "upgrades.ball_repulse_ball.help_plural": "Force de répulsion plus forte", "upgrades.ball_repulse_ball.name": "Vol en formation", "upgrades.base_combo.fullHelp": "Votre combo (nombre de pièces par brique) commence normalement à 1 au début du niveau et revient à 1 lorsque vous rebondissez sans rien toucher. Avec cette caractéristique, le combo commence 3 points plus haut, ce qui fait que vous obtiendrez toujours au moins 4 pièces par brique. Lorsque votre combo est réinitialisé, il revient à 4 et non à 1. Votre balle scintillera un peu pour indiquer que son combo est supérieur à 1.", - "upgrades.base_combo.help": "Chaque brique produit au moins {{coins}} pièces.", + "upgrades.base_combo.help": "Le combo commence à {{coins}}.", "upgrades.base_combo.name": "Combo +3", "upgrades.bigger_explosions.fullHelp": "L'explosion par défaut efface un carré de 3x3 briques, avec cette amélioration un carré de 5x5. Le vent soufflant les pièces est également beaucoup plus fort. L'écran clignotera un peu après chaque explosion (sauf en mode graphismes basiques).", "upgrades.bigger_explosions.help": "Explosions plus violentes", @@ -109,7 +109,7 @@ "upgrades.coin_magnet.help_plural": "Effet plus marqué sur les pièces", "upgrades.coin_magnet.name": "Aimant pour pièces", "upgrades.compound_interest.fullHelp": "Votre combo augmentera d'une unité à chaque fois que vous casserez une brique, générant de plus en plus de pièces à chaque fois que vous casserez une brique. Veillez cependant à attraper chacune de ces pièces avec votre palet, car toute pièce perdue remettra votre combo à zéro. \n \nSi votre combinaison est supérieure au minimum, une ligne rouge s'affichera au bas de la zone de jeu pour vous le rappeler que les pièces ne doivent pas aller à cet endroit.\n\nCet avantage se combine avec d'autres avantages de combo, le combo augmentera plus rapidement mais se réinitialisera plus souvent.", - "upgrades.compound_interest.help": "Attrapez toutes les pièces pour en avoir plus", + "upgrades.compound_interest.help": "+1 combo par brique cassée, remise à zéro quand une pièce est perdu", "upgrades.compound_interest.name": "Intérêts", "upgrades.extra_levels.fullHelp": "La partie dure normalement 7 niveaux, après quoi le jeu est terminé et le score que vous avez atteint est votre score de partie.\n\nChoisir cette amélioration vous permet de prolonger la partie d'un niveau. Les derniers niveaux sont souvent ceux où vous faites le plus de points, la différence peut donc être spectaculaire.", "upgrades.extra_levels.help": "Jouer {{count}} niveaux au lieu de 7", @@ -171,8 +171,8 @@ "upgrades.smaller_puck.help": "Donne aussi +5 combo", "upgrades.smaller_puck.help_plural": "Palet encore plus petit et combinaison de base plus élevée", "upgrades.smaller_puck.name": "Palet plus petit", - "upgrades.soft_reset.fullHelp": "Le combo monte normalement à chaque fois que vous cassez une brique. Ceci annulera parfois cette montée, mais limitera également l'impact d'une réinitialisation du combo.", - "upgrades.soft_reset.help": "Le combo croît plus lentement mais se réinitialise moins", + "upgrades.soft_reset.fullHelp": "Limite l'impact d'une réinitialisation du combo.", + "upgrades.soft_reset.help": "La remise à zéro du combo conserve {{percent}}% des points", "upgrades.soft_reset.name": "Réinitialisation progressive", "upgrades.streak_shots.fullHelp": "Chaque fois que vous cassez une brique, votre combo (nombre de pièces par brique) augmente d'une unité. Cependant, dès que la balle touche votre palet, le combo est remis à sa valeur par défaut, et vous n'obtiendrez qu'une seule pièce par brique.\n\nUne fois que votre combinaison dépasse la valeur de base, votre palet devient rouge pour vous rappeler que le fait de le toucher avec la balle détruira votre combinaison.\n\nCela peut se cumuler avec d'autres avantages liés au combo, le combo augmentera plus rapidement mais se réinitialisera plus facilement car n'importe laquelle des conditions suffit à le réinitialiser.", "upgrades.streak_shots.help": "Plus de pièces si vous cassez plusieurs briques à la fois.", diff --git a/src/newGameState.ts b/src/newGameState.ts new file mode 100644 index 0000000..acc36b9 --- /dev/null +++ b/src/newGameState.ts @@ -0,0 +1,104 @@ +import {GameState, RunParams} from "./types"; +import {getTotalScore} from "./settings"; +import {allLevels, upgrades} from "./loadGameData"; +import {getPossibleUpgrades, makeEmptyPerksMap, sumOfKeys} from "./game_utils"; +import {dontOfferTooSoon, resetBalls} from "./gameStateMutators"; +import {isOptionOn} from "./options"; + +export function newGameState(params: RunParams): GameState { + const totalScoreAtRunStart = getTotalScore(); + const firstLevel = params?.level + ? allLevels.filter((l) => l.name === params?.level) + : []; + + const restInRandomOrder = allLevels + .filter((l) => totalScoreAtRunStart >= l.threshold) + .filter((l) => l.name !== params?.level) + .filter((l) => l.name !== params?.levelToAvoid) + .sort(() => Math.random() - 0.5); + + const runLevels = firstLevel.concat( + restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), + ); + + const perks = {...makeEmptyPerksMap(upgrades), ...(params?.perks || {})}; + + const gameState: GameState = { + runLevels, + currentLevel: 0, + perks, + puckWidth: 200, + baseSpeed: 12, + combo: 1, + gridSize: 12, + running: false, + puckPosition: 400, + pauseTimeout: null, + canvasWidth: 0, + canvasHeight: 0, + offsetX: 0, + offsetXRoundedDown: 0, + gameZoneWidth: 0, + gameZoneWidthRoundedUp: 0, + gameZoneHeight: 0, + brickWidth: 0, + score: 0, + lastScoreIncrease: -1000, + lastExplosion: -1000, + highScore: parseFloat(localStorage.getItem("breakout-3-hs") || "0"), + balls: [], + ballsColor: "white", + bricks: [], + flashes: [], + coins: [], + levelStartScore: 0, + levelMisses: 0, + levelSpawnedCoins: 0, + lastPlayedCoinGrab: 0, + MAX_COINS: 400, + MAX_PARTICLES: 600, + puckColor: "#FFF", + ballSize: 20, + coinSize: 14, + puckHeight: 20, + totalScoreAtRunStart, + isCreativeModeRun: sumOfKeys(perks) > 1, + pauseUsesDuringRun: 0, + keyboardPuckSpeed: 0, + lastTick: performance.now(), + lastTickDown: 0, + runStatistics: { + started: Date.now(), + levelsPlayed: 0, + runTime: 0, + coins_spawned: 0, + score: 0, + bricks_broken: 0, + misses: 0, + balls_lost: 0, + puck_bounces: 0, + upgrades_picked: 1, + max_combo: 1, + max_level: 0, + }, + lastOffered: {}, + levelTime: 0, + autoCleanUses: 0, + }; + resetBalls(gameState); + + if (!sumOfKeys(gameState.perks)) { + const giftable = getPossibleUpgrades(gameState).filter((u) => u.giftable); + const randomGift = + (isOptionOn("easy") && "slow_down") || + giftable[Math.floor(Math.random() * giftable.length)].id; + perks[randomGift] = 1; + dontOfferTooSoon(gameState, randomGift); + } + for (let perk of upgrades) { + if (gameState.perks[perk.id]) { + dontOfferTooSoon(gameState, perk.id); + } + } + return gameState; +} \ No newline at end of file diff --git a/src/options.ts b/src/options.ts index c27cc1c..971daa3 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,71 +1,54 @@ -import {fitSize} from "./game"; import {t} from "./i18n/i18n"; + +import {OptionDef, OptionId} from "./types"; import {getSettingValue, setSettingValue} from "./settings"; export const options = { - sound: { - default: true, - name: t('main_menu.sounds'), - help: t('main_menu.sounds_help'), - afterChange: () => {}, - disabled: () => false, - }, - "mobile-mode": { - default: window.innerHeight > window.innerWidth, - name: t('main_menu.mobile'), - help: t('main_menu.mobile_help'), - afterChange() { - fitSize(); + sound: { + default: true, + name: t('main_menu.sounds'), + help: t('main_menu.sounds_help'), + disabled: () => false, }, - disabled: () => false, - }, - basic: { - default: false, - name: t('main_menu.basic'), - help: t('main_menu.basic_help'), - afterChange: () => {}, - disabled: () => false, - }, - pointerLock: { - default: false, - name: t('main_menu.pointer_lock'), - help: t('main_menu.pointer_lock_help'), - afterChange: () => {}, - disabled: () => !document.body.requestPointerLock, - }, - easy: { - default: false, - name: t('main_menu.kid'), - help: t('main_menu.kid_help'), - afterChange: () => {}, - disabled: () => false, - }, - // Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app - record: { - default: false, - name: t('main_menu.record'), - help: t('main_menu.record_help'), - afterChange: () => {}, - disabled() { - return window.location.search.includes("isInWebView=true"); + "mobile-mode": { + default: window.innerHeight > window.innerWidth, + name: t('main_menu.mobile'), + help: t('main_menu.mobile_help'), + disabled: () => false, + }, + basic: { + default: false, + name: t('main_menu.basic'), + help: t('main_menu.basic_help'), + disabled: () => false, + }, + pointerLock: { + default: false, + name: t('main_menu.pointer_lock'), + help: t('main_menu.pointer_lock_help'), + disabled: () => !document.body.requestPointerLock, + }, + easy: { + default: false, + name: t('main_menu.kid'), + help: t('main_menu.kid_help'), + disabled: () => false, + }, + // Could not get the sharing to work without loading androidx and all the modern android things so for now I'll just disable sharing in the android app + record: { + default: false, + name: t('main_menu.record'), + help: t('main_menu.record_help'), + disabled() { + return window.location.search.includes("isInWebView=true"); + }, }, - }, } as const satisfies { [k: string]: OptionDef }; -export type OptionDef = { - default: boolean; - name: string; - help: string; - disabled: () => boolean; - afterChange: () => void; -}; -export type OptionId = keyof typeof options; - export function isOptionOn(key: OptionId) { - return getSettingValue(key, options[key]?.default) + return getSettingValue("breakout-settings-enable-" + key, options[key]?.default) } export function toggleOption(key: OptionId) { - setSettingValue(key, !isOptionOn(key)) - options[key].afterChange(); + setSettingValue("breakout-settings-enable-" +key, !isOptionOn(key)) } \ No newline at end of file diff --git a/src/rawUpgrades.ts b/src/rawUpgrades.ts index 2680aab..ce5c5ed 100644 --- a/src/rawUpgrades.ts +++ b/src/rawUpgrades.ts @@ -255,9 +255,9 @@ export const rawUpgrades = [ threshold: 18000, giftable: false, id: "soft_reset", - max: 2, + max: 9, name: t('upgrades.soft_reset.name'), - help: (lvl: number) => t('upgrades.soft_reset.help'), + help: (lvl: number) => t('upgrades.soft_reset.help',{percent:10*lvl}), fullHelp: t('upgrades.soft_reset.fullHelp'), diff --git a/src/recording.ts b/src/recording.ts new file mode 100644 index 0000000..8442e9f --- /dev/null +++ b/src/recording.ts @@ -0,0 +1,168 @@ +import {gameCanvas} from "./render"; +import {max_levels} from "./game_utils"; +import {getAudioRecordingTrack} from "./sounds"; +import {t} from "./i18n/i18n"; +import {GameState} from "./types"; +import {isOptionOn} from "./options"; + +let mediaRecorder: MediaRecorder | null, + captureStream: MediaStream, + captureTrack: CanvasCaptureMediaStreamTrack, + recordCanvas: HTMLCanvasElement, + recordCanvasCtx: CanvasRenderingContext2D; + +export function recordOneFrame(gameState:GameState) { + if (!isOptionOn("record")) { + return; + } + if (!gameState.running) return; + if (!captureStream) return; + drawMainCanvasOnSmallCanvas(gameState); + if (captureTrack?.requestFrame) { + captureTrack?.requestFrame(); + } else if (captureStream?.requestFrame) { + captureStream.requestFrame(); + } +} + +export function drawMainCanvasOnSmallCanvas(gameState:GameState) { + if (!recordCanvasCtx) return; + recordCanvasCtx.drawImage( + gameCanvas, + gameState.offsetXRoundedDown, + 0, + gameState.gameZoneWidthRoundedUp, + gameState.gameZoneHeight, + 0, + 0, + recordCanvas.width, + recordCanvas.height, + ); + + // Here we don't use drawText as we don't want to cache a picture for each distinct value of score + recordCanvasCtx.fillStyle = "#FFF"; + recordCanvasCtx.textBaseline = "top"; + recordCanvasCtx.font = "12px monospace"; + recordCanvasCtx.textAlign = "right"; + recordCanvasCtx.fillText( + gameState.score.toString(), + recordCanvas.width - 12, + 12, + ); + + recordCanvasCtx.textAlign = "left"; + recordCanvasCtx.fillText( + "Level " + (gameState.currentLevel + 1) + "/" + max_levels(gameState), + 12, + 12, + ); +} + +export function startRecordingGame(gameState:GameState) { + if (!isOptionOn("record")) { + return; + } + if (mediaRecorder) return; + if (!recordCanvas) { + // Smaller canvas with fewer details + recordCanvas = document.createElement("canvas"); + recordCanvasCtx = recordCanvas.getContext("2d", { + antialias: false, + alpha: false, + }) as CanvasRenderingContext2D; + + captureStream = recordCanvas.captureStream(0); + captureTrack = + captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + + const track = getAudioRecordingTrack(); + if (track) { + captureStream.addTrack(track.stream.getAudioTracks()[0]); + } + } + + recordCanvas.width = gameState.gameZoneWidthRoundedUp; + recordCanvas.height = gameState.gameZoneHeight; + + // drawMainCanvasOnSmallCanvas() + const recordedChunks: Blob[] = []; + + const instance = new MediaRecorder(captureStream, { + videoBitsPerSecond: 3500000, + }); + mediaRecorder = instance; + instance.start(); + mediaRecorder.pause(); + instance.ondataavailable = function (event) { + recordedChunks.push(event.data); + }; + + instance.onstop = async function () { + let targetDiv: HTMLElement | null; + let blob = new Blob(recordedChunks, {type: "video/webm"}); + if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short + + while ( + !(targetDiv = document.getElementById("level-recording-container")) + ) { + await new Promise((r) => setTimeout(r, 200)); + } + const video = document.createElement("video"); + video.autoplay = true; + video.controls = false; + video.disablePictureInPicture = true; + video.disableRemotePlayback = true; + video.width = recordCanvas.width; + video.height = recordCanvas.height; + video.loop = true; + video.muted = true; + video.playsInline = true; + video.src = URL.createObjectURL(blob); + + const a = document.createElement("a"); + a.download = captureFileName("webm"); + a.target = "_blank"; + a.href = video.src; + a.textContent = t('main_menu.record_download', { + size: (blob.size / 1000000).toFixed(2) + }); + targetDiv.appendChild(video); + targetDiv.appendChild(a); + }; +} + +export function pauseRecording( ) { + if (!isOptionOn("record")) { + return; + } + if (mediaRecorder?.state === "recording") { + mediaRecorder?.pause(); + } +} + +export function resumeRecording() { + if (!isOptionOn("record")) { + return; + } + if (mediaRecorder?.state === "paused") { + mediaRecorder.resume(); + } +} + +export function stopRecording() { + if (!isOptionOn("record")) { + return; + } + if (!mediaRecorder) return; + mediaRecorder?.stop(); + mediaRecorder = null; +} + +export function captureFileName(ext = "webm") { + return ( + "breakout-71-capture-" + + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + + "." + + ext + ); +} \ No newline at end of file diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..85ab656 --- /dev/null +++ b/src/render.ts @@ -0,0 +1,717 @@ +import {baseCombo} from "./gameStateMutators"; +import {brickCenterX, brickCenterY, currentLevelInfo, isTelekinesisActive, max_levels} from "./game_utils"; +import {colorString, GameState} from "./types"; +import {t} from "./i18n/i18n"; +import {gameState} from "./game"; +import {isOptionOn} from "./options"; + +export const gameCanvas = document.getElementById("game") as HTMLCanvasElement; +export const ctx = gameCanvas.getContext("2d", { + alpha: false, +}) as CanvasRenderingContext2D; +export const bombSVG = document.createElement("img"); +export const background = document.createElement("img"); +export const backgroundCanvas = document.createElement("canvas"); + +export function render(gameState: GameState) { + + const level = currentLevelInfo(gameState); + const {width, height} = gameCanvas; + if (!width || !height) return; + + if (gameState.currentLevel || gameState.levelTime) { + menuLabel.innerText = t('play.current_lvl', { + level: gameState.currentLevel + 1, + max: max_levels(gameState) + }); + } else { + menuLabel.innerText = t('play.menu_label') + } + scoreDisplay.innerText = `$${gameState.score}`; + + 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; + gameState.coins.forEach((coin) => { + if (!coin.destroyed) + 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; + gameState.flashes.forEach((flash) => { + const {x, y, time, color, size, type, duration} = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "ball") { + drawFuzzyBall(ctx, color, size, x, y); + } + if (type === "particle") { + 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); + 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); + + gameState.flashes.forEach((flash) => { + const {x, y, time, color, size, type, duration} = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "particle") { + drawBall(ctx, color, size, x, y); + } + }); + } + + 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.perks.bigger_explosions && !isOptionOn('basic')) { + if (shaked) { + gameCanvas.style.filter = 'brightness(' + (1 + 100 / (1 + lastExplosionDelay)) + ')'; + } else { + gameCanvas.style.filter = '' + } + } + // Coins + ctx.globalAlpha = 1; + + gameState.coins.forEach((coin) => { + if (!coin.destroyed) { + ctx.globalCompositeOperation = + coin.color === "gold" || level.color ? "source-over" : "screen"; + drawCoin( + ctx, + coin.color, + coin.size, + coin.x, + coin.y, + level.color || "black", + coin.a, + ); + } + }); + + // Black shadow around balls + if (!isOptionOn("basic")) { + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = Math.min(0.8, gameState.coins.length / 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"; + gameState.flashes = gameState.flashes.filter( + (f) => gameState.levelTime - f.time < f.duration && !f.destroyed, + ); + + gameState.flashes.forEach((flash) => { + const {x, y, time, color, size, type, duration} = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); + if (type === "text") { + ctx.globalCompositeOperation = "source-over"; + drawText(ctx, flash.text, color, size, x, y - elapsed / 10); + } else if (type === "particle") { + 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.offsetXRoundedDown, + gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, + gameState.gameZoneWidthRoundedUp, + 1, + ); + } + } + + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + gameState.balls.forEach((ball) => { + // The white border around is to distinguish colored balls from coins/bg + drawBall( + ctx, + gameState.ballsColor, + gameState.ballSize, + ball.x, + ball.y, + gameState.puckColor, + ); + + if (isTelekinesisActive(gameState, ball)) { + ctx.strokeStyle = gameState.puckColor; + ctx.beginPath(); + ctx.bezierCurveTo( + gameState.puckPosition, + gameState.gameZoneHeight, + gameState.puckPosition, + ball.y, + ball.x, + ball.y, + ); + ctx.stroke(); + } + }); + // The puck + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) { + drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2); + } + drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight); + + 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, + comboText, + "#FFF", + gameState.puckHeight, + gameState.puckPosition, + gameState.gameZoneHeight - gameState.puckHeight / 2, + false, + ); + } + } + // Borders + const hasCombo = gameState.combo > baseCombo(gameState); + ctx.globalCompositeOperation = "source-over"; + 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; + ctx.fillRect(gameState.offsetX - 1, 0, 1, height); + ctx.fillStyle = + hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; + ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && gameState.perks.right_is_lava) + ctx.fillRect(width - 1, 0, 1, height); + } + + if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) { + ctx.fillStyle = "red"; + ctx.fillRect( + gameState.offsetXRoundedDown, + 0, + gameState.gameZoneWidthRoundedUp, + 1, + ); + } + const redBottom = + gameState.perks.compound_interest && gameState.combo > baseCombo(gameState); + ctx.fillStyle = redBottom ? "red" : gameState.puckColor; + if (isOptionOn("mobile-mode")) { + ctx.fillRect( + gameState.offsetXRoundedDown, + gameState.gameZoneHeight, + gameState.gameZoneWidthRoundedUp, + 1, + ); + if (!gameState.running) { + drawText( + ctx, + t('play.mobile_press_to_play'), + gameState.puckColor, + gameState.puckHeight, + gameState.canvasWidth / 2, + gameState.gameZoneHeight + + (gameState.canvasHeight - gameState.gameZoneHeight) / 2, + ); + } + } else if (redBottom) { + ctx.fillRect( + gameState.offsetXRoundedDown, + gameState.gameZoneHeight - 1, + gameState.gameZoneWidthRoundedUp, + 1, + ); + } + + if (shaked) { + ctx.resetTransform(); + } + +} + +let cachedBricksRender = document.createElement("canvas"); +let cachedBricksRenderKey = ""; + +export function renderAllBricks() { + ctx.globalAlpha = 1; + + const redBorderOnBricksWithWrongColor = + gameState.combo > baseCombo(gameState) && gameState.perks.picky_eater && !isOptionOn('basic'); + + const newKey = + gameState.gameZoneWidth + + "_" + + gameState.bricks.join("_") + + bombSVG.complete + + "_" + + redBorderOnBricksWithWrongColor + + "_" + + gameState.ballsColor + + "_" + + gameState.perks.pierce_color; + 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); + + if (!color) return; + + const borderColor = + (gameState.ballsColor !== color && + color !== "black" && + redBorderOnBricksWithWrongColor && + "red") || + color; + + drawBrick(canctx, color, borderColor, x, y); + if (color === "black") { + canctx.globalCompositeOperation = "source-over"; + drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); + } + }); + } + + ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); +} + +let cachedGraphics: { [k: string]: HTMLCanvasElement } = {}; + +export function drawPuck( + ctx: CanvasRenderingContext2D, + color: colorString, + puckWidth: number, + puckHeight: number, + yOffset = 0, +) { + const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; + + 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.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(); + 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 = "", +) { + 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 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; + } + 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, +) { + 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; + + 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(); + + if (color === "gold") { + canctx.strokeStyle = borderColor; + canctx.stroke(); + + 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, +) { + 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), + ); +} + +export function drawBrick( + ctx: CanvasRenderingContext2D, + color: colorString, + borderColor: colorString, + x: number, + y: number, +) { + 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 + "_" + borderColor + "_" + width + "_" + height; + + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = width; + can.height = height; + const bord = 2; + const cornerRadius = 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + + canctx.fillStyle = color; + canctx.strokeStyle = borderColor; + canctx.lineJoin = "round"; + canctx.lineWidth = bord; + roundRect( + canctx, + bord / 2, + bord / 2, + width - bord, + height - bord, + cornerRadius, + ); + canctx.fill(); + 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.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, +) { + const key = "svg" + img + "_" + size + "_" + img.complete; + + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; + + 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); + + 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, +) { + 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"; + + 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), + ); +} + +export const scoreDisplay = document.getElementById("score") as HTMLButtonElement; +const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement; \ No newline at end of file diff --git a/src/resetBalls.ts b/src/resetBalls.ts deleted file mode 100644 index efff9b2..0000000 --- a/src/resetBalls.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { GameState } from "./types"; -import { getMajorityValue } from "./game_utils"; - -export function resetBalls(gameState: 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 = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed; - - 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, - - sx: 0, - sy: 0, - sparks: 0, - piercedSinceBounce: 0, - hitSinceBounce: 0, - hitItem: [], - bouncesList: [], - sapperUses: 0, - }); - } -} - -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); - 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.vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed; - ball.previousVX = ball.vx; - ball.vy = -gameState.baseSpeed; - ball.previousVY = ball.vy; - ball.sx = 0; - ball.sy = 0; - ball.hitItem = []; - ball.hitSinceBounce = 0; - ball.piercedSinceBounce = 0; - }); -} diff --git a/src/settings.ts b/src/settings.ts index a9cedad..b30b9d4 100644 --- a/src/settings.ts +++ b/src/settings.ts @@ -1,11 +1,13 @@ // Settings +import {GameState} from "./types"; + let cachedSettings: { [key: string]: unknown } = {}; export function getSettingValue(key: string, defaultValue: T) { if (typeof cachedSettings[key] == "undefined") { try { - const ls = localStorage.getItem("breakout-settings-enable-" + key); + const ls = localStorage.getItem( key); if (ls) cachedSettings[key] = JSON.parse(ls) as T; } catch (e) { console.warn(e); @@ -17,9 +19,19 @@ export function getSettingValue(key: string, defaultValue: T) { export function setSettingValue(key: string, value: T) { cachedSettings[key] = value try { - localStorage.setItem("breakout-settings-enable-" + key, JSON.stringify(value)); + localStorage.setItem( key, JSON.stringify(value)); } catch (e) { console.warn(e); } } +export function getTotalScore() { + return getSettingValue('breakout_71_total_score', 0) + +} + +export function addToTotalScore(gameState: GameState, points: number) { + if (gameState.isCreativeModeRun) return; + setSettingValue('breakout_71_total_score', getTotalScore() + points) +} + diff --git a/src/sounds.ts b/src/sounds.ts index dc5172d..a221eee 100644 --- a/src/sounds.ts +++ b/src/sounds.ts @@ -1,5 +1,6 @@ import { gameState } from "./game"; + import {isOptionOn} from "./options"; export const sounds = { diff --git a/src/types.d.ts b/src/types.d.ts index 6da77fe..f1e7fbb 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,4 +1,5 @@ -import { rawUpgrades } from "./rawUpgrades"; +import {rawUpgrades} from "./rawUpgrades"; +import {options} from "./options"; export type colorString = string; @@ -194,8 +195,7 @@ export type GameState = { // Will be set if the game is about to be paused. Game pause is delayed by a few milliseconds if you pause a few times in a run, // to avoid abuse of the "release to pause" feature on mobile. pauseTimeout: NodeJS.Timeout | null; - // Whether the game should be rendered at the next tick, even if the game is paused - needsRender: boolean; + // Current run score score: number; // levelTime of the last time the score increase, to render the score differently @@ -241,3 +241,10 @@ export type RunParams = { levelToAvoid?: string; perks?: Partial; }; +export type OptionDef = { + default: boolean; + name: string; + help: string; + disabled: () => boolean; +}; +export type OptionId = keyof typeof options; \ No newline at end of file