diff --git a/Readme.md b/Readme.md index e7bcc5d..f4a70b4 100644 --- a/Readme.md +++ b/Readme.md @@ -25,10 +25,6 @@ There's also an easy mode for kids (slower ball). - people assume unbounded allows for wrap around - popups not scrollable sometimes - fdroid build -- deal with too many upgrades : - - disable some upgrades to remove them from the pool - - reroll mechanic - - extra option that just adds 10% to score - coin magnet and viscosity : only one level ~2.5 - Boost Ascetism : give +2 or even +3 combo per brick destroyed - wind : move coins based on puck movement not position diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4010d70..17b17ac 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.lecaro.breakout" minSdk = 21 targetSdk = 34 - versionCode = 29048147 - versionName = "29048147" + versionCode = 29049575 + versionName = "29049575" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/assets/index.html b/app/src/main/assets/index.html index 2aa8135..58fc649 100644 --- a/app/src/main/assets/index.html +++ b/app/src/main/assets/index.html @@ -1 +1 @@ -Breakout 71
\ No newline at end of file +Breakout 71
\ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 29b90e7..42a4f07 100644 --- a/dist/index.html +++ b/dist/index.html @@ -631,7 +631,6 @@ var _newGameState = require("./newGameState"); var _asyncAlert = require("./asyncAlert"); var _options = require("./options"); var _getLevelBackground = require("./getLevelBackground"); -var _premium = require("./premium"); function play() { if (gameState.running) return; gameState.running = true; @@ -754,8 +753,8 @@ async function openShortRunUpgradesPicker(gameState) { count: gameState.rerolls }), help: (0, _i18N.t)("level_up.reroll_help"), - value: 'reroll', - icon: (0, _loadGameData.icons)['icon:reroll'] + value: "reroll", + icon: (0, _loadGameData.icons)["icon:reroll"] }); if (!actions.length) break; let textAfterButtons = ` @@ -790,7 +789,7 @@ async function openShortRunUpgradesPicker(gameState) { allowClose: false, textAfterButtons }); - if (upgradeId === 'reroll') { + if (upgradeId === "reroll") { repeats++; gameState.rerolls--; } else { @@ -962,14 +961,7 @@ async function openMainMenu() { } } }, - (0, _premium.premiumMenuEntry)(gameState), - // - // { - // icon: icons["icon:continue"], - // text: t("main_menu.resume"), - // help: t("main_menu.resume_help"), - // value() {}, - // }, + // premiumMenuEntry(gameState), { text: (0, _i18N.t)("main_menu.settings_title"), help: (0, _i18N.t)("main_menu.settings_help"), @@ -1343,7 +1335,7 @@ restart(window.location.search.includes("stressTest") ? { } : {}); tick(); -},{"./loadGameData":"l1B4x","./sounds":"dQKPV","./game_utils":"cEeac","./PWA/sw_loader":"2n0gK","./i18n/i18n":"eNPRm","./settings":"5blfu","./gameStateMutators":"9ZeQl","./render":"9AS2t","./recording":"godmD","./newGameState":"aQN6X","./asyncAlert":"rSqLY","./options":"d5NoS","./getLevelBackground":"7OIPf","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./premium":"4GEPs"}],"l1B4x":[function(require,module,exports,__globalThis) { +},{"./loadGameData":"l1B4x","./sounds":"dQKPV","./game_utils":"cEeac","./PWA/sw_loader":"2n0gK","./i18n/i18n":"eNPRm","./settings":"5blfu","./gameStateMutators":"9ZeQl","./render":"9AS2t","./recording":"godmD","./newGameState":"aQN6X","./asyncAlert":"rSqLY","./options":"d5NoS","./getLevelBackground":"7OIPf","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"l1B4x":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); parcelHelpers.defineInteropFlag(exports); parcelHelpers.export(exports, "appVersion", ()=>appVersion); @@ -1384,7 +1376,7 @@ const upgrades = (0, _upgrades.rawUpgrades).map((u)=>({ })); },{"./data/palette.json":"ktRBU","./data/levels.json":"8JSUc","./data/version.json":"iyP6E","./upgrades":"1u3Dx","./getLevelBackground":"7OIPf","./levelIcon":"6rQoT","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"iyP6E":[function(require,module,exports,__globalThis) { -module.exports = JSON.parse("\"29048147\""); +module.exports = JSON.parse("\"29049575\""); },{}],"1u3Dx":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); @@ -4385,8 +4377,8 @@ function newGameState(params) { autoCleanUses: 0, ...(0, _gameUtils.defaultSounds)(), isAdventureMode: !!params?.adventure, - adventurePath: '', - seed: 'Seed' + Math.random(), + adventurePath: "", + seed: "Seed" + Math.random(), rerolls: 0 }; (0, _gameStateMutators.resetBalls)(gameState); @@ -4400,130 +4392,7 @@ function newGameState(params) { return gameState; } -},{"./settings":"5blfu","./loadGameData":"l1B4x","./game_utils":"cEeac","./gameStateMutators":"9ZeQl","./options":"d5NoS","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}],"4GEPs":[function(require,module,exports,__globalThis) { -var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); -parcelHelpers.defineInteropFlag(exports); -parcelHelpers.export(exports, "isPremium", ()=>isPremium); -parcelHelpers.export(exports, "premiumMenuEntry", ()=>premiumMenuEntry); -var _loadGameData = require("./loadGameData"); -var _i18N = require("./i18n/i18n"); -var _settings = require("./settings"); -var _asyncAlert = require("./asyncAlert"); -var _game = require("./game"); -const publicKeyString = `-----BEGIN PUBLIC KEY----- -MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGQJgs6gxa0Fd86TuZ2q -rGQ5ArSn8ug4VIKezru1QhIEkXeOT1lYXOLEryWaVUwXfOa9sVlKAGJY5y0TarAY -NF2m67ME8yzNPIoZWbKXutJ3CSCXNTjAqAxHgz7H+qxbNGZXAXw+ta8+PuZDzcCI -LbXT1u3/i0ahhA2Erdpv9XQBazKZt5AKzU31XhEEFh1jXZyk9D4XbatYXtvEwaJx -eSWmjSxJ6SJb6oH2mwm8V4E0PxYVIa0yX3cPgGuR0pZPMleOTc6o0T24I2AUQb0d -FckdFrr5U8bFIf/nwncMYVVNgt1vh88EuzWLjpc52nLrdOkVQNpiCN2uMgBBXQB7 -iseIfdkGF0A4DBn8qdieDvaSY8zeRW/nAce4FNBidU1SebNRnIU9f/XpA493lJW+ -Y/zXQBbmX/uSmeZDP4fjhKZv0Qa0ZeGzZiTdBKKb0BlIg/VYFFsqPytUVVyesO4J -RCASTIjXW61E7PQKir5qIXwkQDlzJ+bpZ3PHyAvspRrBaDxIYvEEw14evpuqOgS+ -v/IlgPe+CWSvZa9xxnQl/aWZrOrD7syu6KKCbgUyXEm+Alp0YT3e6nwjn0qiM/cj -dZpWPx3O+rZbRQb0gHcvN4+n2Y7fWAeC9mxVZtADqvVr/GTumMbLj7DdhWtt1Ogu -4EcvkQ5SKCL0JC93DyctjOMCAwEAAQ== ------END PUBLIC KEY-----`; -function pemToArrayBuffer(pem) { - const b64 = pem.replace(/-----BEGIN PUBLIC KEY-----/, '').replace(/-----END PUBLIC KEY-----/, '').replace(/\s+/g, ''); - const binaryDerString = atob(b64); - const binaryDer = new Uint8Array(binaryDerString.length); - for(let i = 0; i < binaryDerString.length; i++)binaryDer[i] = binaryDerString.charCodeAt(i); - return binaryDer.buffer; -} -async function getPriceId(key, pem) { - // Split the key into its components - const [priceId, timestamp, signature] = key.split(':'); - const data = `${priceId}:${timestamp}`; - const publicKeyBuffer = pemToArrayBuffer(pem); - const publicKey = await crypto.subtle.importKey('spki', publicKeyBuffer, { - name: 'RSA-PSS', - hash: 'SHA-256' - }, true, [ - 'verify' - ]); - // Verify the signature using ECDSA - const isValid = await crypto.subtle.verify({ - name: 'RSA-PSS', - saltLength: 32 - }, publicKey, new Uint8Array(Array.from(atob(signature), (c)=>c.charCodeAt(0))), new TextEncoder().encode(data)); - if (!isValid) throw new Error("Invalid key signature"); - return priceId; -} -let premium = false; -const gamePriceId = 'price_1R6YaEGRf74lr2EkSo2GPvuO'; -checkKey((0, _settings.getSettingValue)('license', '')).then(); -async function checkKey(key) { - if (!key) return 'No key'; - try { - if (gamePriceId !== await getPriceId(key, publicKeyString)) return 'Wrong product'; - premium = true; - return ''; - } catch (e) { - return 'Could not upgrade : ' + e.message; - } -} -function isPremium() { - return premium; -} -function premiumMenuEntry(gameState) { - if (isPremium()) return { - icon: (0, _loadGameData.icons)["icon:adventure_mode"], - text: (0, _i18N.t)("premium.adventure_mode"), - help: (0, _i18N.t)("premium.adventure_mode_help"), - value: async ()=>{ - if (await (0, _game.confirmRestart)(gameState)) (0, _game.restart)({ - adventure: true - }); - } - }; - return { - icon: (0, _loadGameData.icons)["icon:premium"], - text: (0, _i18N.t)("premium.title"), - help: (0, _i18N.t)("premium.short_help"), - value: ()=>openPremiumMenu('') - }; -} -async function openPremiumMenu(text) { - const isGooglePlayInstall = new URLSearchParams(location.search).get('source') === 'com.android.vending'; - const cb = await (0, _asyncAlert.asyncAlert)({ - title: (0, _i18N.t)("premium.title"), - text: text || isGooglePlayInstall && (0, _i18N.t)("premium.help_google") || (0, _i18N.t)("premium.help"), - actions: [ - { - text: (0, _i18N.t)("premium.buy"), - disabled: isGooglePlayInstall, - help: isGooglePlayInstall ? (0, _i18N.t)("premium.buy_disabled_help") : (0, _i18N.t)("premium.buy_help"), - value () { - window.open('https://licenses.lecaro.me/buy/price_1R6YaEGRf74lr2EkSo2GPvuO', '_blank'); - } - }, - { - text: (0, _i18N.t)("premium.enter"), - help: (0, _i18N.t)("premium.enter_help"), - async value () { - const value = (prompt('Please paste your license key') || '').replace(/\s+/g, ''); - const problem = await checkKey(value); - if (problem) openPremiumMenu(problem).then(); - else { - (0, _settings.setSettingValue)('license', value); - (0, _game.openMainMenu)().then(); - } - } - }, - { - text: (0, _i18N.t)("premium.back"), - help: (0, _i18N.t)("premium.back_help"), - value () { - (0, _game.openMainMenu)().then(); - } - } - ] - }); - if (cb) cb(); -} - -},{"@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./loadGameData":"l1B4x","./i18n/i18n":"eNPRm","./settings":"5blfu","./asyncAlert":"rSqLY","./game":"edeGs"}]},["gVqJ6","67XFf"], "67XFf", "parcelRequire94c2") +},{"./settings":"5blfu","./loadGameData":"l1B4x","./game_utils":"cEeac","./gameStateMutators":"9ZeQl","./options":"d5NoS","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3"}]},["gVqJ6","67XFf"], "67XFf", "parcelRequire94c2") diff --git a/src/PWA/sw-b71.js b/src/PWA/sw-b71.js index c804058..c10b293 100644 --- a/src/PWA/sw-b71.js +++ b/src/PWA/sw-b71.js @@ -1,5 +1,5 @@ // The version of the cache. -const VERSION = "29048147"; +const VERSION = "29049575"; // The name of the cache const CACHE_NAME = `breakout-71-${VERSION}`; diff --git a/src/adventure.ts b/src/adventure.ts index 1b7a550..07ef092 100644 --- a/src/adventure.ts +++ b/src/adventure.ts @@ -1,30 +1,26 @@ -import {GameState} from "./types"; +import { GameState } from "./types"; export async function openAdventureRunUpgradesPicker(gameState: GameState) { - let options=3 - const catchRate = + let options = 3; + const catchRate = (gameState.score - gameState.levelStartScore) / (gameState.levelSpawnedCoins || 1); - if (gameState.levelWallBounces == 0) { options++; } if (gameState.levelTime < 30 * 1000) { - options++ + options++; } if (catchRate === 1) { - options++ + options++; } if (gameState.levelMisses === 0) { - options++ + options++; } - const choices = [] - for( let difficulty=0; difficulty 0.9) { - gameState.rerolls++ + gameState.rerolls++; catchGain = t("level_up.plus_one_choice"); } if (gameState.levelMisses === 0) { repeats++; - gameState.rerolls++ + gameState.rerolls++; missesGain = t("level_up.plus_one_upgrade"); } else if (gameState.levelMisses <= 3) { - gameState.rerolls++ + gameState.rerolls++; missesGain = t("level_up.plus_one_choice"); } while (repeats--) { const actions = pickRandomUpgrades( gameState, - 3 + - gameState.perks.one_more_choice - - gameState.perks.instant_upgrade, + 3 + gameState.perks.one_more_choice - gameState.perks.instant_upgrade, ); - if(gameState.rerolls){ + if (gameState.rerolls) { actions.push({ - text: t("level_up.reroll",{count:gameState.rerolls}), + text: t("level_up.reroll", { count: gameState.rerolls }), help: t("level_up.reroll_help"), - value: 'reroll', - icon: icons['icon:reroll'] - }) + value: "reroll", + icon: icons["icon:reroll"], + }); } if (!actions.length) break; let textAfterButtons = ` @@ -242,7 +262,7 @@ export async function openShortRunUpgradesPicker(gameState: GameState) { t("level_up.compliment_good")) || t("level_up.compliment_advice"); - const upgradeId = (await asyncAlert({ + const upgradeId = (await asyncAlert({ title: t("level_up.pick_upgrade_title") + (repeats ? " (" + (repeats + 1) + ")" : ""), @@ -267,10 +287,10 @@ export async function openShortRunUpgradesPicker(gameState: GameState) { textAfterButtons, })) as PerkId; - if(upgradeId==='reroll'){ - repeats++ - gameState.rerolls-- - }else{ + if (upgradeId === "reroll") { + repeats++; + gameState.rerolls--; + } else { gameState.perks[upgradeId]++; if (upgradeId === "instant_upgrade") { repeats += 2; @@ -280,7 +300,6 @@ export async function openShortRunUpgradesPicker(gameState: GameState) { } } - gameCanvas.addEventListener("mouseup", (e) => { if (e.button !== 0) return; if (gameState.running) { @@ -450,7 +469,7 @@ export async function openMainMenu() { const creativeModeThreshold = Math.max(...upgrades.map((u) => u.threshold)); const actions: AsyncAlertAction<() => void>[] = [ - { + { icon: icons["icon:7_levels_run"], text: t("main_menu.normal"), help: t("main_menu.normal_help"), @@ -514,15 +533,7 @@ export async function openMainMenu() { }, }, - premiumMenuEntry(gameState) - , - // - // { - // icon: icons["icon:continue"], - // text: t("main_menu.resume"), - // help: t("main_menu.resume_help"), - // value() {}, - // }, + // premiumMenuEntry(gameState), { text: t("main_menu.settings_title"), help: t("main_menu.settings_help"), @@ -765,7 +776,11 @@ async function openSettingsMenu() { ], allowClose: true, }); - if (pick && pick !== getCurrentLang() && (await confirmRestart(gameState))) { + if ( + pick && + pick !== getCurrentLang() && + (await confirmRestart(gameState)) + ) { setSettingValue("lang", pick); window.location.reload(); } @@ -852,7 +867,7 @@ Click an item above to start a run with it.

`, actions, allowClose: true, - actionsAsGrid:true + actionsAsGrid: true, }); if (tryOn) { if (await confirmRestart(gameState)) { diff --git a/src/gameStateMutators.ts b/src/gameStateMutators.ts index f35fe82..64b3236 100644 --- a/src/gameStateMutators.ts +++ b/src/gameStateMutators.ts @@ -1,1721 +1,1729 @@ import { - Ball, - BallLike, - Coin, - colorString, - GameState, - LightFlash, - ParticleFlash, - PerkId, - ReusableArray, - TextFlash, + Ball, + BallLike, + Coin, + colorString, + GameState, + LightFlash, + ParticleFlash, + PerkId, + ReusableArray, + TextFlash, } from "./types"; import { - brickCenterX, - brickCenterY, - clamp, - countBricksAbove, - countBricksBelow, - currentLevelInfo, - distance2, - distanceBetween, - getMajorityValue, - getPossibleUpgrades, - getRowColIndex, - isTelekinesisActive, - isYoyoActive, - max_levels, - shouldPierceByColor, + brickCenterX, + brickCenterY, + clamp, + countBricksAbove, + countBricksBelow, + currentLevelInfo, + distance2, + distanceBetween, + getMajorityValue, + getPossibleUpgrades, + getRowColIndex, + isTelekinesisActive, + isYoyoActive, + max_levels, + shouldPierceByColor, } from "./game_utils"; -import {t} from "./i18n/i18n"; -import {icons} from "./loadGameData"; +import { t } from "./i18n/i18n"; +import { icons } from "./loadGameData"; -import {addToTotalScore, getCurrentMaxCoins, getCurrentMaxParticles,} from "./settings"; -import {background} from "./render"; -import {gameOver} from "./gameOver"; -import {brickIndex, fitSize, gameState, hasBrick, hitsSomething, openShortRunUpgradesPicker, pause,} from "./game"; -import {stopRecording} from "./recording"; -import {isOptionOn} from "./options"; -import {openAdventureRunUpgradesPicker} from "./adventure"; +import { + addToTotalScore, + getCurrentMaxCoins, + getCurrentMaxParticles, +} from "./settings"; +import { background } from "./render"; +import { gameOver } from "./gameOver"; +import { + brickIndex, + fitSize, + gameState, + hasBrick, + hitsSomething, + openShortRunUpgradesPicker, + pause, +} from "./game"; +import { stopRecording } from "./recording"; +import { isOptionOn } from "./options"; +import { openAdventureRunUpgradesPicker } from "./adventure"; 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; - gameState.needsRender = true; + // Sets the puck position, and updates the ball position if they are supposed to follow it + gameState.puckPosition = x; + gameState.needsRender = true; } function getBallDefaultVx(gameState: GameState) { - return ( - (gameState.perks.concave_puck ? 0 : 1) * - (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed) - ); + return ( + (gameState.perks.concave_puck ? 0 : 1) * + (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed) + ); } export function resetBalls(gameState: GameState) { - // Always compute speed first - normalizeGameState(gameState); - const count = 1 + (gameState.perks?.multiball || 0); - const perBall = gameState.puckWidth / (count + 1); - gameState.balls = []; - gameState.ballsColor = "#FFF"; - if (gameState.perks.picky_eater || gameState.perks.pierce_color) { - gameState.ballsColor = - getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF"; - } - for (let i = 0; i < count; i++) { - const x = - gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); - const vx = getBallDefaultVx(gameState); + // Always compute speed first + normalizeGameState(gameState); + const count = 1 + (gameState.perks?.multiball || 0); + const perBall = gameState.puckWidth / (count + 1); + gameState.balls = []; + gameState.ballsColor = "#FFF"; + if (gameState.perks.picky_eater || gameState.perks.pierce_color) { + gameState.ballsColor = + getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF"; + } + for (let i = 0; i < count; i++) { + const x = + gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); + const vx = getBallDefaultVx(gameState); - gameState.balls.push({ - x, - previousX: x, - y: gameState.gameZoneHeight - 1.5 * gameState.ballSize, - previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize, - vx, - previousVX: vx, - vy: -gameState.baseSpeed, - previousVY: -gameState.baseSpeed, + 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, - piercePoints: gameState.perks.pierce * 3, - hitSinceBounce: 0, - brokenSinceBounce: 0, - hitItem: [], - sapperUses: 0, - }); - } - gameState.ballStickToPuck = true; + // sx: 0, + // sy: 0, + piercePoints: gameState.perks.pierce * 3, + hitSinceBounce: 0, + brokenSinceBounce: 0, + hitItem: [], + sapperUses: 0, + }); + } + gameState.ballStickToPuck = true; } export function putBallsAtPuck(gameState: GameState) { - // This reset could be abused to cheat quite easily - const count = gameState.balls.length; - const perBall = gameState.puckWidth / (count + 1); - // const vx = getBallDefaultVx(gameState); - gameState.balls.forEach((ball, i) => { - const x = - gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); + // This reset could be abused to cheat quite easily + const count = gameState.balls.length; + const perBall = gameState.puckWidth / (count + 1); + // const vx = getBallDefaultVx(gameState); + gameState.balls.forEach((ball, i) => { + const x = + gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); - ball.x = x; - ball.previousX = x; - ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize; - ball.previousY = ball.y; - // ball.vx = vx; - // ball.previousVX = ball.vx; - // ball.vy = -gameState.baseSpeed; - // ball.previousVY = ball.vy; - // ball.sx = 0; - // ball.sy = 0; - ball.hitItem = []; - ball.hitSinceBounce = 0; - ball.brokenSinceBounce = 0; - ball.piercePoints = gameState.perks.pierce * 3; - }); + ball.x = x; + ball.previousX = x; + ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize; + ball.previousY = ball.y; + // ball.vx = vx; + // ball.previousVX = ball.vx; + // ball.vy = -gameState.baseSpeed; + // ball.previousVY = ball.vy; + // ball.sx = 0; + // ball.sy = 0; + ball.hitItem = []; + ball.hitSinceBounce = 0; + ball.brokenSinceBounce = 0; + ball.piercePoints = gameState.perks.pierce * 3; + }); } export function normalizeGameState(gameState: GameState) { - // This function resets most parameters on the state to correct values, and should be used even when the game is paused + // This function resets most parameters on the state to correct values, and should be used even when the game is paused - gameState.baseSpeed = Math.max( - 3, - gameState.gameZoneWidth / 12 / 10 + - gameState.currentLevel / 3 + - gameState.levelTime / (30 * 1000) - - gameState.perks.slow_down * 2, - ); + gameState.baseSpeed = Math.max( + 3, + gameState.gameZoneWidth / 12 / 10 + + gameState.currentLevel / 3 + + gameState.levelTime / (30 * 1000) - + gameState.perks.slow_down * 2, + ); - gameState.puckWidth = - (gameState.gameZoneWidth / 12) * - (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); + gameState.puckWidth = + (gameState.gameZoneWidth / 12) * + (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); - let minX = - gameState.perks.corner_shot && gameState.levelTime - ? gameState.offsetXRoundedDown - gameState.puckWidth / 2 - : gameState.offsetXRoundedDown + gameState.puckWidth / 2; + let minX = + gameState.perks.corner_shot && gameState.levelTime + ? gameState.offsetXRoundedDown - gameState.puckWidth / 2 + : gameState.offsetXRoundedDown + gameState.puckWidth / 2; - let maxX = - gameState.perks.corner_shot && gameState.levelTime - ? gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp + - gameState.puckWidth / 2 - : gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp - - gameState.puckWidth / 2; + let maxX = + gameState.perks.corner_shot && gameState.levelTime + ? gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp + + gameState.puckWidth / 2 + : gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp - + gameState.puckWidth / 2; - gameState.puckPosition = clamp(gameState.puckPosition, minX, maxX); + gameState.puckPosition = clamp(gameState.puckPosition, minX, maxX); - if (gameState.ballStickToPuck) { - putBallsAtPuck(gameState); - } + if (gameState.ballStickToPuck) { + putBallsAtPuck(gameState); + } - if ( - Math.abs(gameState.lastPuckPosition - gameState.puckPosition) > 1 && - gameState.running - ) { - gameState.lastPuckMove = gameState.levelTime; - } - gameState.lastPuckPosition = gameState.puckPosition; + if ( + Math.abs(gameState.lastPuckPosition - gameState.puckPosition) > 1 && + gameState.running + ) { + gameState.lastPuckMove = gameState.levelTime; + } + gameState.lastPuckPosition = gameState.puckPosition; } export function baseCombo(gameState: GameState) { - return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; + return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; } export function resetCombo( - gameState: GameState, - x: number | undefined, - y: number | undefined, + gameState: GameState, + x: number | undefined, + y: number | undefined, ) { - const prev = gameState.combo; - gameState.combo = baseCombo(gameState); + const prev = gameState.combo; + gameState.combo = baseCombo(gameState); - if (prev > gameState.combo && gameState.perks.soft_reset) { - gameState.combo += Math.floor( - ((prev - gameState.combo) * (gameState.perks.soft_reset * 10)) / 100, - ); + 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( + () => schedulGameSound(gameState, "comboDecrease", x, 1), + i * 100, + ); } - const lost = Math.max(0, prev - gameState.combo); - if (lost) { - for (let i = 0; i < lost && i < 8; i++) { - setTimeout( - () => schedulGameSound(gameState, "comboDecrease", x, 1), - i * 100, - ); - } - if (typeof x !== "undefined" && typeof y !== "undefined") { - makeText(gameState, x, y, "red", "-" + lost, 20, 150); - } + if (typeof x !== "undefined" && typeof y !== "undefined") { + makeText(gameState, x, y, "red", "-" + lost, 20, 150); } - return lost; + } + return lost; } export function decreaseCombo( - gameState: GameState, - by: number, - x: number, - y: number, + gameState: GameState, + by: number, + x: number, + y: number, ) { - const prev = gameState.combo; - gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by); - const lost = Math.max(0, prev - gameState.combo); + const prev = gameState.combo; + gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by); + const lost = Math.max(0, prev - gameState.combo); - if (lost) { - schedulGameSound(gameState, "comboDecrease", x, 1); - if (typeof x !== "undefined" && typeof y !== "undefined") { - makeText(gameState, x, y, "red", "-" + lost, 20, 300); - } + if (lost) { + schedulGameSound(gameState, "comboDecrease", x, 1); + if (typeof x !== "undefined" && typeof y !== "undefined") { + makeText(gameState, x, y, "red", "-" + lost, 20, 300); } + } } export function spawnExplosion( - gameState: GameState, - count: number, - x: number, - y: number, - color: string, + gameState: GameState, + count: number, + x: number, + y: number, + color: string, ) { - if (!!isOptionOn("basic")) return; + if (!!isOptionOn("basic")) return; - if (liveCount(gameState.particles) > getCurrentMaxParticles()) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - makeParticle( - gameState, + if (liveCount(gameState.particles) > getCurrentMaxParticles()) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + makeParticle( + gameState, - x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - (Math.random() - 0.5) * 30, - (Math.random() - 0.5) * 30, - color, - false, - ); - } + x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + (Math.random() - 0.5) * 30, + (Math.random() - 0.5) * 30, + color, + false, + ); + } } export function spawnImplosion( - gameState: GameState, - count: number, - x: number, - y: number, - color: string, + gameState: GameState, + count: number, + x: number, + y: number, + color: string, ) { - if (!!isOptionOn("basic")) return; + if (!!isOptionOn("basic")) return; - if (liveCount(gameState.particles) > getCurrentMaxParticles()) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - const dx = ((Math.random() - 0.5) * gameState.brickWidth) / 2; - const dy = ((Math.random() - 0.5) * gameState.brickWidth) / 2; - makeParticle(gameState, x - dx * 10, y - dy * 10, dx, dy, color, false); - } + if (liveCount(gameState.particles) > getCurrentMaxParticles()) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + const dx = ((Math.random() - 0.5) * gameState.brickWidth) / 2; + const dy = ((Math.random() - 0.5) * gameState.brickWidth) / 2; + makeParticle(gameState, x - dx * 10, y - dy * 10, dx, dy, color, false); + } } export function explosionAt( - gameState: GameState, - index: number, - x: number, - y: number, - ball: Ball, + gameState: GameState, + index: number, + x: number, + y: number, + ball: Ball, ) { - const size = 1 + gameState.perks.bigger_explosions; - schedulGameSound(gameState, "explode", ball.x, 1); - if (index !== -1) { - const col = index % gameState.gridSize; - const row = Math.floor(index / gameState.gridSize); - // Break bricks around - for (let dx = -size; dx <= size; dx++) { - for (let dy = -size; dy <= size; dy++) { - const i = getRowColIndex(gameState, row + dy, col + dx); - if (gameState.bricks[i] && i !== -1) { - // Study bricks resist explosions too - gameState.brickHP[i]--; - if (gameState.brickHP[i] <= 0) { - explodeBrick(gameState, i, ball, true); - } - } - } + const size = 1 + gameState.perks.bigger_explosions; + schedulGameSound(gameState, "explode", ball.x, 1); + if (index !== -1) { + const col = index % gameState.gridSize; + const row = Math.floor(index / gameState.gridSize); + // Break bricks around + for (let dx = -size; dx <= size; dx++) { + for (let dy = -size; dy <= size; dy++) { + const i = getRowColIndex(gameState, row + dy, col + dx); + if (gameState.bricks[i] && i !== -1) { + // Study bricks resist explosions too + gameState.brickHP[i]--; + if (gameState.brickHP[i] <= 0) { + explodeBrick(gameState, i, ball, true); + } } + } } + } - const factor = gameState.perks.implosions ? -1 : 1; - // Blow nearby coins - forEachLiveOne(gameState.coins, (c) => { - const dx = c.x - x; - const dy = c.y - y; - const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy)); - c.vx += (((dx / d2) * 10 * size) / c.weight) * factor; - c.vy += (((dy / d2) * 10 * size) / c.weight) * factor; - }); - gameState.lastExplosion = Date.now(); + const factor = gameState.perks.implosions ? -1 : 1; + // Blow nearby coins + forEachLiveOne(gameState.coins, (c) => { + const dx = c.x - x; + const dy = c.y - y; + const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy)); + c.vx += (((dx / d2) * 10 * size) / c.weight) * factor; + c.vy += (((dy / d2) * 10 * size) / c.weight) * factor; + }); + gameState.lastExplosion = Date.now(); - makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150); - if (gameState.perks.implosions) { - spawnImplosion( - gameState, - 7 * (1 + gameState.perks.bigger_explosions), - x, - y, - "white", - ); - } else { - spawnExplosion( - gameState, - 7 * (1 + gameState.perks.bigger_explosions), - x, - y, - "white", - ); - } + makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150); + if (gameState.perks.implosions) { + spawnImplosion( + gameState, + 7 * (1 + gameState.perks.bigger_explosions), + x, + y, + "white", + ); + } else { + spawnExplosion( + gameState, + 7 * (1 + gameState.perks.bigger_explosions), + x, + y, + "white", + ); + } - gameState.runStatistics.bricks_broken++; + gameState.runStatistics.bricks_broken++; - if (gameState.perks.zen) { - resetCombo(gameState, x, y); - } + if (gameState.perks.zen) { + resetCombo(gameState, x, y); + } } export function explodeBrick( - gameState: GameState, - index: number, - ball: Ball, - isExplosion: boolean, + gameState: GameState, + index: number, + ball: Ball, + isExplosion: boolean, ) { - const color = gameState.bricks[index]; - if (!color) return; + const color = gameState.bricks[index]; + if (!color) return; - if (color === "black") { - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); - setBrick(gameState, index, ""); - explosionAt(gameState, index, x, y, ball); - } else if (color) { - // Even if it bounces we don't want to count that as a miss + if (color === "black") { + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); + setBrick(gameState, index, ""); + explosionAt(gameState, index, x, y, ball); + } else if (color) { + // Even if it bounces we don't want to count that as a miss - // Flashing is take care of by the tick loop - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); + // Flashing is take care of by the tick loop + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); - setBrick(gameState, index, ""); + setBrick(gameState, index, ""); - let coinsToSpawn = gameState.combo; - if (gameState.perks.sturdy_bricks) { - // +10% per level - coinsToSpawn += Math.ceil( - ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn, - ); - } - - gameState.levelSpawnedCoins += coinsToSpawn; - gameState.runStatistics.coins_spawned += coinsToSpawn; - gameState.runStatistics.bricks_broken++; - const maxCoins = getCurrentMaxCoins() * (isOptionOn("basic") ? 0.5 : 1); - const spawnableCoins = - liveCount(gameState.coins) > getCurrentMaxCoins() - ? 1 - : Math.floor(maxCoins - liveCount(gameState.coins)) / 3; - - const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); - - while (coinsToSpawn > 0) { - const points = Math.min(pointsPerCoin, coinsToSpawn); - if (points < 0 || isNaN(points)) { - console.error({points}); - debugger; - } - - coinsToSpawn -= points; - - const cx = - x + - (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize), - cy = - y + - (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize); - makeCoin( - gameState, - cx, - cy, - ball.previousVX * (0.5 + Math.random()), - ball.previousVY * (0.5 + Math.random()), - gameState.perks.metamorphosis ? color : "gold", - points, - ); - } - - gameState.combo += - gameState.perks.streak_shots + - gameState.perks.compound_interest + - gameState.perks.left_is_lava + - gameState.perks.right_is_lava + - gameState.perks.top_is_lava + - gameState.perks.picky_eater + - gameState.perks.asceticism + - gameState.perks.zen + - gameState.perks.passive_income + - gameState.perks.nbricks + - gameState.perks.unbounded; - - if (gameState.perks.side_kick) { - if (Math.abs(ball.vx) > Math.abs(ball.vy)) { - gameState.combo += gameState.perks.side_kick; - } else { - decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y); - } - } - - if (gameState.perks.reach) { - if ( - countBricksAbove(gameState, index) && - !countBricksBelow(gameState, index) - ) { - resetCombo(gameState, x, y); - } else { - gameState.combo += gameState.perks.reach; - } - } - - if ( - gameState.lastPuckMove && - gameState.perks.passive_income && - gameState.lastPuckMove > - gameState.levelTime - 250 * gameState.perks.passive_income - ) { - resetCombo(gameState, x, y); - } - - if ( - gameState.perks.nbricks && - ball.brokenSinceBounce == gameState.perks.nbricks + 1 - ) { - resetCombo(gameState, ball.x, ball.y); - } - - if (!isExplosion) { - // color change - if ( - (gameState.perks.picky_eater || gameState.perks.pierce_color) && - color !== gameState.ballsColor && - color - ) { - if (gameState.perks.picky_eater) { - resetCombo(gameState, ball.x, ball.y); - } - schedulGameSound(gameState, "colorChange", ball.x, 0.8); - gameState.lastExplosion = gameState.levelTime; - gameState.ballsColor = color; - if (!isOptionOn("basic")) { - gameState.balls.forEach((ball) => { - spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color); - }); - } - } else { - schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1); - } - } - makeLight(gameState, x, y, color, gameState.brickWidth, 40); - - spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color); + let coinsToSpawn = gameState.combo; + if (gameState.perks.sturdy_bricks) { + // +10% per level + coinsToSpawn += Math.ceil( + ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn, + ); } - if (!gameState.bricks[index] && color !== "black") { - ball.hitItem?.push({ - index, - color, - }); + gameState.levelSpawnedCoins += coinsToSpawn; + gameState.runStatistics.coins_spawned += coinsToSpawn; + gameState.runStatistics.bricks_broken++; + const maxCoins = getCurrentMaxCoins() * (isOptionOn("basic") ? 0.5 : 1); + const spawnableCoins = + liveCount(gameState.coins) > getCurrentMaxCoins() + ? 1 + : Math.floor(maxCoins - liveCount(gameState.coins)) / 3; + + const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); + + while (coinsToSpawn > 0) { + const points = Math.min(pointsPerCoin, coinsToSpawn); + if (points < 0 || isNaN(points)) { + console.error({ points }); + debugger; + } + + coinsToSpawn -= points; + + const cx = + x + + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize), + cy = + y + + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize); + makeCoin( + gameState, + cx, + cy, + ball.previousVX * (0.5 + Math.random()), + ball.previousVY * (0.5 + Math.random()), + gameState.perks.metamorphosis ? color : "gold", + points, + ); } + + gameState.combo += + gameState.perks.streak_shots + + gameState.perks.compound_interest + + gameState.perks.left_is_lava + + gameState.perks.right_is_lava + + gameState.perks.top_is_lava + + gameState.perks.picky_eater + + gameState.perks.asceticism + + gameState.perks.zen + + gameState.perks.passive_income + + gameState.perks.nbricks + + gameState.perks.unbounded; + + if (gameState.perks.side_kick) { + if (Math.abs(ball.vx) > Math.abs(ball.vy)) { + gameState.combo += gameState.perks.side_kick; + } else { + decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y); + } + } + + if (gameState.perks.reach) { + if ( + countBricksAbove(gameState, index) && + !countBricksBelow(gameState, index) + ) { + resetCombo(gameState, x, y); + } else { + gameState.combo += gameState.perks.reach; + } + } + + if ( + gameState.lastPuckMove && + gameState.perks.passive_income && + gameState.lastPuckMove > + gameState.levelTime - 250 * gameState.perks.passive_income + ) { + resetCombo(gameState, x, y); + } + + if ( + gameState.perks.nbricks && + ball.brokenSinceBounce == gameState.perks.nbricks + 1 + ) { + resetCombo(gameState, ball.x, ball.y); + } + + if (!isExplosion) { + // color change + if ( + (gameState.perks.picky_eater || gameState.perks.pierce_color) && + color !== gameState.ballsColor && + color + ) { + if (gameState.perks.picky_eater) { + resetCombo(gameState, ball.x, ball.y); + } + schedulGameSound(gameState, "colorChange", ball.x, 0.8); + gameState.lastExplosion = gameState.levelTime; + gameState.ballsColor = color; + if (!isOptionOn("basic")) { + gameState.balls.forEach((ball) => { + spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color); + }); + } + } else { + schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1); + } + } + makeLight(gameState, x, y, color, gameState.brickWidth, 40); + + spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color); + } + + if (!gameState.bricks[index] && color !== "black") { + ball.hitItem?.push({ + index, + color, + }); + } } export function dontOfferTooSoon(gameState: GameState, id: PerkId) { - gameState.lastOffered[id] = Math.round(Date.now() / 1000); + gameState.lastOffered[id] = Math.round(Date.now() / 1000); } export function pickRandomUpgrades(gameState: GameState, count: number) { - let list = getPossibleUpgrades(gameState) - .map((u) => ({ - ...u, - score: Math.random() + (gameState.lastOffered[u.id] || 0), - })) - .sort((a, b) => a.score - b.score) - .filter((u) => gameState.perks[u.id] < u.max) - .slice(0, count) - .sort((a, b) => (a.id > b.id ? 1 : -1)); + let list = getPossibleUpgrades(gameState) + .map((u) => ({ + ...u, + score: Math.random() + (gameState.lastOffered[u.id] || 0), + })) + .sort((a, b) => a.score - b.score) + .filter((u) => gameState.perks[u.id] < u.max) + .slice(0, count) + .sort((a, b) => (a.id > b.id ? 1 : -1)); - list.forEach((u) => { - dontOfferTooSoon(gameState, u.id); - }); + list.forEach((u) => { + dontOfferTooSoon(gameState, u.id); + }); - return list.map((u) => ({ - text: - u.name + - (gameState.perks[u.id] - ? t("level_up.upgrade_perk_to_level", { - level: gameState.perks[u.id] + 1, - }) - : ""), - icon: icons["icon:" + u.id], - value: u.id as PerkId, - help: u.help(gameState.perks[u.id] + 1), - })); + return list.map((u) => ({ + text: + u.name + + (gameState.perks[u.id] + ? t("level_up.upgrade_perk_to_level", { + level: gameState.perks[u.id] + 1, + }) + : ""), + icon: icons["icon:" + u.id], + value: u.id as PerkId, + help: u.help(gameState.perks[u.id] + 1), + })); } export function schedulGameSound( - gameState: GameState, - sound: keyof GameState["aboutToPlaySound"], - x: number | void, - vol: number, + gameState: GameState, + sound: keyof GameState["aboutToPlaySound"], + x: number | void, + vol: number, ) { - if (!vol) return; - x ??= gameState.offsetX + gameState.gameZoneWidth / 2; - const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number }; + if (!vol) return; + x ??= gameState.offsetX + gameState.gameZoneWidth / 2; + const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number }; - ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol); - ex.vol += vol; + ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol); + ex.vol += vol; } export function addToScore(gameState: GameState, coin: Coin) { - gameState.score += coin.points; - gameState.lastScoreIncrease = gameState.levelTime; + gameState.score += coin.points; + gameState.lastScoreIncrease = gameState.levelTime; - addToTotalScore(gameState, coin.points); - if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { - gameState.highScore = gameState.score; - localStorage.setItem("breakout-3-hs", gameState.score.toString()); - } - if (!isOptionOn("basic")) { - makeParticle( - gameState, - coin.previousX, - coin.previousY, - (gameState.canvasWidth - coin.x) / 100, - -coin.y / 100, - coin.color, - true, - gameState.coinSize / 2, - 100 + Math.random() * 50, - ); - } + addToTotalScore(gameState, coin.points); + if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { + gameState.highScore = gameState.score; + localStorage.setItem("breakout-3-hs", gameState.score.toString()); + } + if (!isOptionOn("basic")) { + makeParticle( + gameState, + coin.previousX, + coin.previousY, + (gameState.canvasWidth - coin.x) / 100, + -coin.y / 100, + coin.color, + true, + gameState.coinSize / 2, + 100 + Math.random() * 50, + ); + } - if (Date.now() - gameState.lastPlayedCoinGrab > 16) { - gameState.lastPlayedCoinGrab = Date.now(); + if (Date.now() - gameState.lastPlayedCoinGrab > 16) { + gameState.lastPlayedCoinGrab = Date.now(); - schedulGameSound(gameState, "coinCatch", coin.x, 1); - } - gameState.runStatistics.score += coin.points; - if (gameState.perks.asceticism) { - resetCombo(gameState, coin.x, coin.y); - } + schedulGameSound(gameState, "coinCatch", coin.x, 1); + } + gameState.runStatistics.score += coin.points; + if (gameState.perks.asceticism) { + resetCombo(gameState, coin.x, coin.y); + } } export async function setLevel(gameState: GameState, l: number) { - // Here to alleviate double upgrades issues - if (gameState.upgradesOfferedFor >= l) { - debugger; - return console.warn("Extra upgrade request ignored "); + // Here to alleviate double upgrades issues + if (gameState.upgradesOfferedFor >= l) { + debugger; + return console.warn("Extra upgrade request ignored "); + } + gameState.upgradesOfferedFor = l; + pause(false); + stopRecording(); + if (l > 0) { + if (gameState.isCreativeModeRun) { + await openAdventureRunUpgradesPicker(gameState); + } else { + await openShortRunUpgradesPicker(gameState); } - gameState.upgradesOfferedFor = l; - pause(false); - stopRecording(); - if (l > 0) { - if(gameState.isCreativeModeRun){ - await openAdventureRunUpgradesPicker(gameState); - }else{ + } + gameState.currentLevel = l; + gameState.levelTime = 0; + gameState.winAt = 0; + gameState.levelWallBounces = 0; + gameState.autoCleanUses = 0; + gameState.lastTickDown = gameState.levelTime; + gameState.levelStartScore = gameState.score; + gameState.levelSpawnedCoins = 0; + gameState.levelMisses = 0; + gameState.runStatistics.levelsPlayed++; - await openShortRunUpgradesPicker(gameState); - } - } - gameState.currentLevel = l; - gameState.levelTime = 0; - gameState.winAt = 0; - gameState.levelWallBounces = 0; - gameState.autoCleanUses = 0; - gameState.lastTickDown = gameState.levelTime; - gameState.levelStartScore = gameState.score; - gameState.levelSpawnedCoins = 0; - gameState.levelMisses = 0; - gameState.runStatistics.levelsPlayed++; + // Reset combo silently + const finalCombo = gameState.combo; + gameState.combo = baseCombo(gameState); + if (gameState.perks.shunt) { + gameState.combo += Math.round( + Math.max( + 0, + ((finalCombo - gameState.combo) * 20 * gameState.perks.shunt) / 100, + ), + ); + } + gameState.combo += gameState.perks.hot_start * 15; - // Reset combo silently - const finalCombo = gameState.combo; - gameState.combo = baseCombo(gameState); - if (gameState.perks.shunt) { - gameState.combo += Math.round( - Math.max( - 0, - ((finalCombo - gameState.combo) * 20 * gameState.perks.shunt) / 100, - ), - ); - } - gameState.combo += gameState.perks.hot_start * 15; + const lvl = currentLevelInfo(gameState); + if (lvl.size !== gameState.gridSize) { + gameState.gridSize = lvl.size; + fitSize(); + } + empty(gameState.coins); + empty(gameState.particles); + empty(gameState.lights); + empty(gameState.texts); + gameState.bricks = []; + for (let i = 0; i < lvl.size * lvl.size; i++) { + setBrick(gameState, i, lvl.bricks[i]); + } - const lvl = currentLevelInfo(gameState); - if (lvl.size !== gameState.gridSize) { - gameState.gridSize = lvl.size; - fitSize(); - } - empty(gameState.coins); - empty(gameState.particles); - empty(gameState.lights); - empty(gameState.texts); - gameState.bricks = []; - for (let i = 0; i < lvl.size * lvl.size; i++) { - setBrick(gameState, i, lvl.bricks[i]); - } - - // Balls color will depend on most common brick color sometimes - resetBalls(gameState); - gameState.needsRender = true; - // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons - // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) - background.src = "data:image/svg+xml;UTF8," + lvl.svg; + // Balls color will depend on most common brick color sometimes + resetBalls(gameState); + gameState.needsRender = true; + // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons + // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) + background.src = "data:image/svg+xml;UTF8," + lvl.svg; } function setBrick(gameState: GameState, index: number, color: string) { - gameState.bricks[index] = color || ""; - gameState.brickHP[index] = - (color === "black" && 1) || - (color && 1 + gameState.perks.sturdy_bricks) || - 0; + gameState.bricks[index] = color || ""; + gameState.brickHP[index] = + (color === "black" && 1) || + (color && 1 + gameState.perks.sturdy_bricks) || + 0; } export function rainbowColor(): colorString { - return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; + return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; } export function repulse( - gameState: GameState, - a: Ball, - b: BallLike, - power: number, - impactsBToo: boolean, + gameState: GameState, + a: Ball, + b: BallLike, + power: number, + impactsBToo: boolean, ) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameState.gameZoneWidth / 4; - if (distance > max) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; - const fact = - (((-power * (max - distance)) / (max * 1.2) / 3) * - Math.min(500, gameState.levelTime)) / - 500; - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - b.vx += dx * fact; - b.vy += dy * fact; - } - a.vx -= dx * fact; - a.vy -= dy * fact; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const max = gameState.gameZoneWidth / 4; + if (distance > max) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; + const fact = + (((-power * (max - distance)) / (max * 1.2) / 3) * + Math.min(500, gameState.levelTime)) / + 500; + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { + b.vx += dx * fact; + b.vy += dy * fact; + } + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; + makeParticle( + gameState, + a.x, + a.y, + -dx * speed + a.vx + (Math.random() - 0.5) * rand, + -dy * speed + a.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { makeParticle( - gameState, - a.x, - a.y, - -dx * speed + a.vx + (Math.random() - 0.5) * rand, - -dy * speed + a.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, + gameState, + b.x, + b.y, + dx * speed + b.vx + (Math.random() - 0.5) * rand, + dy * speed + b.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, ); - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - makeParticle( - gameState, - b.x, - b.y, - dx * speed + b.vx + (Math.random() - 0.5) * rand, - dy * speed + b.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); - } + } } export function attract(gameState: GameState, a: Ball, b: Ball, power: number) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = (gameState.gameZoneWidth * 3) / 4; - if (distance < min) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const min = (gameState.gameZoneWidth * 3) / 4; + if (distance < min) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; - const fact = - (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) / - 500; - b.vx += dx * fact; - b.vy += dy * fact; - a.vx -= dx * fact; - a.vy -= dy * fact; + const fact = + (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) / + 500; + b.vx += dx * fact; + b.vy += dy * fact; + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; - makeParticle( - gameState, - a.x, - a.y, - dx * speed + a.vx + (Math.random() - 0.5) * rand, - dy * speed + a.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); - makeParticle( - gameState, - b.x, - b.y, - -dx * speed + b.vx + (Math.random() - 0.5) * rand, - -dy * speed + b.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); + makeParticle( + gameState, + a.x, + a.y, + dx * speed + a.vx + (Math.random() - 0.5) * rand, + dy * speed + a.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); + makeParticle( + gameState, + b.x, + b.y, + -dx * speed + b.vx + (Math.random() - 0.5) * rand, + -dy * speed + b.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); } export function coinBrickHitCheck(gameState: GameState, coin: Coin) { - // Make ball/coin bonce, and return bricks that were hit - const radius = coin.size / 2; - const {x, y, previousX, previousY} = coin; + // Make ball/coin bonce, and return bricks that were hit + const radius = coin.size / 2; + const { x, y, previousX, previousY } = coin; - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; - if (!gameState.perks.ghost_coins) { - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - coin.y = coin.previousY; - coin.vy *= -1; + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; + if (!gameState.perks.ghost_coins) { + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + coin.y = coin.previousY; + coin.vy *= -1; - // Roll on corners - const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)]; - const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)]; + // Roll on corners + const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)]; + const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)]; - if (leftHit && !rightHit) { - coin.vx += 1; - coin.sa -= 1; - } - if (!leftHit && rightHit) { - coin.vx -= 1; - coin.sa += 1; - } - } - if (typeof hhit !== "undefined" || typeof chit !== "undefined") { - coin.x = coin.previousX; - coin.vx *= -1; - } + if (leftHit && !rightHit) { + coin.vx += 1; + coin.sa -= 1; + } + if (!leftHit && rightHit) { + coin.vx -= 1; + coin.sa += 1; + } } - return vhit ?? hhit ?? chit; + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + coin.x = coin.previousX; + coin.vx *= -1; + } + } + return vhit ?? hhit ?? chit; } export function bordersHitCheck( - gameState: GameState, - coin: Coin | Ball, - radius: number, - delta: number, + gameState: GameState, + coin: Coin | Ball, + radius: number, + delta: number, ) { - if (coin.destroyed) return; - coin.previousX = coin.x; - coin.previousY = coin.y; - coin.x += coin.vx * delta; - coin.y += coin.vy * delta; - // coin.sx ||= 0; - // coin.sy ||= 0; - // coin.sx += coin.previousX - coin.x; - // coin.sy += coin.previousY - coin.y; - // coin.sx *= 0.9; - // coin.sy *= 0.9; + if (coin.destroyed) return; + coin.previousX = coin.x; + coin.previousY = coin.y; + coin.x += coin.vx * delta; + coin.y += coin.vy * delta; + // coin.sx ||= 0; + // coin.sy ||= 0; + // coin.sx += coin.previousX - coin.x; + // coin.sy += coin.previousY - coin.y; + // coin.sx *= 0.9; + // coin.sy *= 0.9; - if (gameState.perks.wind) { - coin.vx += - ((gameState.puckPosition - - (gameState.offsetX + gameState.gameZoneWidth / 2)) / - gameState.gameZoneWidth) * - gameState.perks.wind * - 0.5; - } + if (gameState.perks.wind) { + coin.vx += + ((gameState.puckPosition - + (gameState.offsetX + gameState.gameZoneWidth / 2)) / + gameState.gameZoneWidth) * + gameState.perks.wind * + 0.5; + } - let vhit = 0, - hhit = 0; + let vhit = 0, + hhit = 0; - if ( - coin.x < gameState.offsetXRoundedDown + radius && - !gameState.perks.unbounded - ) { - coin.x = - gameState.offsetXRoundedDown + - radius + - (gameState.offsetXRoundedDown + radius - coin.x); - coin.vx *= -1; - hhit = 1; - } - if (coin.y < radius) { - coin.y = radius + (radius - coin.y); - coin.vy *= -1; - vhit = 1; - } - if ( - coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius && - !gameState.perks.unbounded - ) { - coin.x = - gameState.canvasWidth - - gameState.offsetXRoundedDown - - radius - - (coin.x - - (gameState.canvasWidth - gameState.offsetXRoundedDown - radius)); - coin.vx *= -1; - hhit = 1; - } + if ( + coin.x < gameState.offsetXRoundedDown + radius && + !gameState.perks.unbounded + ) { + coin.x = + gameState.offsetXRoundedDown + + radius + + (gameState.offsetXRoundedDown + radius - coin.x); + coin.vx *= -1; + hhit = 1; + } + if (coin.y < radius) { + coin.y = radius + (radius - coin.y); + coin.vy *= -1; + vhit = 1; + } + if ( + coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius && + !gameState.perks.unbounded + ) { + coin.x = + gameState.canvasWidth - + gameState.offsetXRoundedDown - + radius - + (coin.x - + (gameState.canvasWidth - gameState.offsetXRoundedDown - radius)); + coin.vx *= -1; + hhit = 1; + } - return hhit + vhit * 2; + return hhit + vhit * 2; } export function gameStateTick( - gameState: GameState, - // How many frames to compute at once, can go above 1 to compensate lag - frames = 1, + gameState: GameState, + // How many frames to compute at once, can go above 1 to compensate lag + frames = 1, ) { - gameState.runStatistics.max_combo = Math.max( - gameState.runStatistics.max_combo, - gameState.combo, + gameState.runStatistics.max_combo = Math.max( + gameState.runStatistics.max_combo, + gameState.combo, + ); + + gameState.balls = gameState.balls.filter((ball) => !ball.destroyed); + + const remainingBricks = gameState.bricks.filter( + (b) => b && b !== "black", + ).length; + + if ( + gameState.levelTime > gameState.lastTickDown + 1000 && + gameState.perks.hot_start + ) { + gameState.lastTickDown = gameState.levelTime; + decreaseCombo( + gameState, + gameState.perks.hot_start, + gameState.puckPosition, + gameState.gameZoneHeight - 2 * gameState.puckHeight, ); + } - gameState.balls = gameState.balls.filter((ball) => !ball.destroyed); + if ( + remainingBricks <= gameState.perks.skip_last && + !gameState.autoCleanUses + ) { + gameState.bricks.forEach((type, index) => { + if (type) { + explodeBrick(gameState, index, gameState.balls[0], true); + } + }); + gameState.autoCleanUses++; + } + const hasPendingBricks = + gameState.perks.respawn && + gameState.balls.find((b) => b.hitItem.length > 1); - 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 (gameState.running && !remainingBricks && !hasPendingBricks) { + if (!gameState.winAt) { + gameState.winAt = gameState.levelTime + 5000; } + } else { + gameState.winAt = 0; + } - if ( - remainingBricks <= gameState.perks.skip_last && - !gameState.autoCleanUses - ) { - gameState.bricks.forEach((type, index) => { - if (type) { - explodeBrick(gameState, index, gameState.balls[0], true); - } - }); - gameState.autoCleanUses++; - } - const hasPendingBricks = - gameState.perks.respawn && - gameState.balls.find((b) => b.hitItem.length > 1); - - if (gameState.running && !remainingBricks && !hasPendingBricks) { - if (!gameState.winAt) { - gameState.winAt = gameState.levelTime + 5000; - } + if ( + // Delayed win when coins are still flying + (gameState.winAt && gameState.levelTime > gameState.winAt) || + // instant win condition + (gameState.running && + gameState.levelTime && + !remainingBricks && + !liveCount(gameState.coins)) + ) { + if (gameState.currentLevel + 1 < max_levels(gameState)) { + if (gameState.running) { + setLevel(gameState, gameState.currentLevel + 1); + } } else { - gameState.winAt = 0; + gameOver( + t("gameOver.win.title"), + t("gameOver.win.summary", { score: gameState.score }), + ); } + } else if (gameState.running || gameState.levelTime) { + const coinRadius = Math.round(gameState.coinSize / 2); - if ( - // Delayed win when coins are still flying - (gameState.winAt && gameState.levelTime > gameState.winAt) || - // instant win condition - (gameState.running && - gameState.levelTime && - !remainingBricks && - !liveCount(gameState.coins)) - ) { - if (gameState.currentLevel + 1 < max_levels(gameState)) { - if (gameState.running) { - setLevel(gameState, gameState.currentLevel + 1); - } - } else { - gameOver( - t("gameOver.win.title"), - t("gameOver.win.summary", {score: gameState.score}), - ); - } - } else if (gameState.running || gameState.levelTime) { - const coinRadius = Math.round(gameState.coinSize / 2); + forEachLiveOne(gameState.coins, (coin, coinIndex) => { + if (gameState.perks.coin_magnet) { + const strength = + (100 / + (100 + + Math.pow(coin.y - gameState.gameZoneHeight, 2) + + Math.pow(coin.x - gameState.puckPosition, 2))) * + gameState.perks.coin_magnet; - forEachLiveOne(gameState.coins, (coin, coinIndex) => { - if (gameState.perks.coin_magnet) { - const strength = - (100 / - (100 + - Math.pow(coin.y - gameState.gameZoneHeight, 2) + - Math.pow(coin.x - gameState.puckPosition, 2))) * - gameState.perks.coin_magnet; + const attractionX = + frames * (gameState.puckPosition - coin.x) * strength; - const attractionX = - frames * (gameState.puckPosition - coin.x) * strength; + coin.vx += attractionX; + coin.vy += + (frames * (gameState.gameZoneHeight - coin.y) * strength) / 2; + coin.sa -= attractionX / 10; + } - coin.vx += attractionX; - coin.vy += - (frames * (gameState.gameZoneHeight - coin.y) * strength) / 2; - coin.sa -= attractionX / 10; - } - - if (gameState.perks.ball_attracts_coins) { - gameState.balls.forEach((ball) => { - const d2 = distance2(ball, coin); - coin.vx += - ((ball.x - coin.x) / d2) * 30 * gameState.perks.ball_attracts_coins; - coin.vy += - ((ball.y - coin.y) / d2) * 30 * gameState.perks.ball_attracts_coins; - }); - } - - 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 - if (!gameState.perks.etherealcoins) { - const flip = - gameState.perks.helium > 0 && - Math.abs(coin.x - gameState.puckPosition) * 2 > - gameState.puckWidth + coin.size; - coin.vy += frames * coin.weight * 0.8 * (flip ? -1 : 1); - if (flip && !isOptionOn("basic") && Math.random() < 0.1) { - makeParticle( - gameState, - coin.x, - coin.y, - 0, - gameState.baseSpeed, - coin.color, - true, - 5, - 250, - ); - } - } - - const speed = (Math.abs(coin.vx) + Math.abs(coin.vy)) * 10; - const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); - - if ( - coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight && - coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy && - Math.abs(coin.x - gameState.puckPosition) < - coinRadius + - gameState.puckWidth / 2 + // a bit of margin to be nice - gameState.puckHeight - ) { - addToScore(gameState, coin); - destroy(gameState.coins, coinIndex); - } else if (coin.y > gameState.canvasHeight + coinRadius) { - destroy(gameState.coins, coinIndex); - if (gameState.perks.compound_interest) { - resetCombo(gameState, coin.x, coin.y); - } - } else if ( - gameState.perks.unbounded && - (coin.x < -gameState.gameZoneWidth / 2 || - coin.x > gameState.canvasWidth + gameState.gameZoneWidth / 2) - ) { - // Out of bound on sides - destroy(gameState.coins, coinIndex); - } - - const hitBrick = coinBrickHitCheck(gameState, coin); - - if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { - if ( - gameState.bricks[hitBrick] && - coin.color !== gameState.bricks[hitBrick] && - gameState.bricks[hitBrick] !== "black" && - !coin.coloredABrick - ) { - // Not using setbrick because we don't want to reset HP - gameState.bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - - schedulGameSound(gameState, "colorChange", coin.x, 0.3); - } - } - - if ( - (!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") || - hitBorder - ) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20) { - schedulGameSound(gameState, "coinBounce", coin.x, 0.2); - } - - if (Math.abs(coin.vy) < 3) { - coin.vy = 0; - } - } + if (gameState.perks.ball_attracts_coins) { + gameState.balls.forEach((ball) => { + const d2 = distance2(ball, coin); + coin.vx += + ((ball.x - coin.x) / d2) * 30 * gameState.perks.ball_attracts_coins; + coin.vy += + ((ball.y - coin.y) / d2) * 30 * gameState.perks.ball_attracts_coins; }); + } - gameState.balls.forEach((ball) => ballTick(gameState, ball, frames)); + const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames; - if (gameState.perks.shocks) { - gameState.balls.forEach((a, ai) => - gameState.balls.forEach((b, bi) => { - if ( - ai < bi && - !a.destroyed && - !b.destroyed && - distance2(a, b) < gameState.ballSize * gameState.ballSize - ) { - let tempVx = a.vx; - let tempVy = a.vy; - a.vx = b.vx; - a.vy = b.vy; - b.vx = tempVx; - b.vy = tempVy; + coin.vy *= ratio; + coin.vx *= ratio; + if (coin.vx > 7 * gameState.baseSpeed) coin.vx = 7 * gameState.baseSpeed; + if (coin.vx < -7 * gameState.baseSpeed) + coin.vx = -7 * gameState.baseSpeed; + if (coin.vy > 7 * gameState.baseSpeed) coin.vy = 7 * gameState.baseSpeed; + if (coin.vy < -7 * gameState.baseSpeed) + coin.vy = -7 * gameState.baseSpeed; + coin.a += coin.sa; - let x = (a.x + b.x) / 2; - let y = (a.y + b.y) / 2; - const limit = gameState.baseSpeed; - a.vx += - clamp(a.x - x, -limit, limit) + - ((Math.random() - 0.5) * limit) / 3; - a.vy += - clamp(a.y - y, -limit, limit) + - ((Math.random() - 0.5) * limit) / 3; - b.vx += - clamp(b.x - x, -limit, limit) + - ((Math.random() - 0.5) * limit) / 3; - b.vy += - clamp(b.y - y, -limit, limit) + - ((Math.random() - 0.5) * limit) / 3; - - let index = brickIndex(x, y); - explosionAt(gameState, index, x, y, a); - } - }), - ); + // Gravity + if (!gameState.perks.etherealcoins) { + const flip = + gameState.perks.helium > 0 && + Math.abs(coin.x - gameState.puckPosition) * 2 > + gameState.puckWidth + coin.size; + coin.vy += frames * coin.weight * 0.8 * (flip ? -1 : 1); + if (flip && !isOptionOn("basic") && Math.random() < 0.1) { + makeParticle( + gameState, + coin.x, + coin.y, + 0, + gameState.baseSpeed, + coin.color, + true, + 5, + 250, + ); } + } - if (gameState.perks.wind) { - const windD = - ((gameState.puckPosition - - (gameState.offsetX + gameState.gameZoneWidth / 2)) / - gameState.gameZoneWidth) * - 2 * - gameState.perks.wind; - for (let i = 0; i < gameState.perks.wind; i++) { - if (Math.random() * Math.abs(windD) > 0.5) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - Math.random() * gameState.gameZoneHeight, - windD * 8, - 0, - rainbowColor(), - true, - gameState.coinSize / 2, - 150, - ); - } - } - } - forEachLiveOne(gameState.particles, (flash, index) => { - flash.x += flash.vx * frames; - flash.y += flash.vy * frames; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) { - destroy(gameState.particles, index); - } - } - }); - } - - if ( - gameState.combo > baseCombo(gameState) && - !isOptionOn("basic") && - (gameState.combo - baseCombo(gameState)) * Math.random() > 5 - ) { - // The red should still be visible on a white bg - - if (gameState.perks.top_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - 0, - (Math.random() - 0.5) * 10, - 5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } - - if (gameState.perks.left_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown, - Math.random() * gameState.gameZoneHeight, - 5, - (Math.random() - 0.5) * 10, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } - - if (gameState.perks.right_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, - Math.random() * gameState.gameZoneHeight, - -5, - (Math.random() - 0.5) * 10, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } + const speed = (Math.abs(coin.vx) + Math.abs(coin.vy)) * 10; + const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); + if ( + coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight && + coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy && + Math.abs(coin.x - gameState.puckPosition) < + coinRadius + + gameState.puckWidth / 2 + // a bit of margin to be nice + gameState.puckHeight + ) { + addToScore(gameState, coin); + destroy(gameState.coins, coinIndex); + } else if (coin.y > gameState.canvasHeight + coinRadius) { + destroy(gameState.coins, coinIndex); if (gameState.perks.compound_interest) { - let x = gameState.puckPosition, - attemps = 0; - do { - x = - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp * Math.random(); - attemps++; - } while ( - Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 && - attemps < 10 - ); + resetCombo(gameState, coin.x, coin.y); + } + } else if ( + gameState.perks.unbounded && + (coin.x < -gameState.gameZoneWidth / 2 || + coin.x > gameState.canvasWidth + gameState.gameZoneWidth / 2) + ) { + // Out of bound on sides + destroy(gameState.coins, coinIndex); + } - makeParticle( - gameState, - x, - gameState.gameZoneHeight, - (Math.random() - 0.5) * 10, - -5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); + const hitBrick = coinBrickHitCheck(gameState, coin); + + if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { + if ( + gameState.bricks[hitBrick] && + coin.color !== gameState.bricks[hitBrick] && + gameState.bricks[hitBrick] !== "black" && + !coin.coloredABrick + ) { + // Not using setbrick because we don't want to reset HP + gameState.bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + + schedulGameSound(gameState, "colorChange", coin.x, 0.3); } - if (gameState.perks.streak_shots) { - const pos = 0.5 - Math.random(); - makeParticle( - gameState, - gameState.puckPosition + gameState.puckWidth * pos, - gameState.gameZoneHeight - gameState.puckHeight, - pos * 10, - -5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); + } + + if ( + (!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") || + hitBorder + ) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20) { + schedulGameSound(gameState, "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.shocks) { + gameState.balls.forEach((a, ai) => + gameState.balls.forEach((b, bi) => { + if ( + ai < bi && + !a.destroyed && + !b.destroyed && + distance2(a, b) < gameState.ballSize * gameState.ballSize + ) { + let tempVx = a.vx; + let tempVy = a.vy; + a.vx = b.vx; + a.vy = b.vy; + b.vx = tempVx; + b.vy = tempVy; + + let x = (a.x + b.x) / 2; + let y = (a.y + b.y) / 2; + const limit = gameState.baseSpeed; + a.vx += + clamp(a.x - x, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + a.vy += + clamp(a.y - y, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + b.vx += + clamp(b.x - x, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + b.vy += + clamp(b.y - y, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + + let index = brickIndex(x, y); + explosionAt(gameState, index, x, y, a); + } + }), + ); } - forEachLiveOne(gameState.particles, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.particles, pi); + if (gameState.perks.wind) { + const windD = + ((gameState.puckPosition - + (gameState.offsetX + gameState.gameZoneWidth / 2)) / + gameState.gameZoneWidth) * + 2 * + gameState.perks.wind; + for (let i = 0; i < gameState.perks.wind; i++) { + if (Math.random() * Math.abs(windD) > 0.5) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + Math.random() * gameState.gameZoneHeight, + windD * 8, + 0, + rainbowColor(), + true, + gameState.coinSize / 2, + 150, + ); } - }); - forEachLiveOne(gameState.texts, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.texts, pi); - } - }); - forEachLiveOne(gameState.lights, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.lights, pi); + } + } + forEachLiveOne(gameState.particles, (flash, index) => { + flash.x += flash.vx * frames; + flash.y += flash.vy * frames; + if (!flash.ethereal) { + flash.vy += 0.5; + if (hasBrick(brickIndex(flash.x, flash.y))) { + destroy(gameState.particles, index); } + } }); + } + + if ( + gameState.combo > baseCombo(gameState) && + !isOptionOn("basic") && + (gameState.combo - baseCombo(gameState)) * Math.random() > 5 + ) { + // The red should still be visible on a white bg + + if (gameState.perks.top_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + 0, + (Math.random() - 0.5) * 10, + 5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.left_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown, + Math.random() * gameState.gameZoneHeight, + 5, + (Math.random() - 0.5) * 10, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.right_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, + Math.random() * gameState.gameZoneHeight, + -5, + (Math.random() - 0.5) * 10, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.compound_interest) { + let x = gameState.puckPosition, + attemps = 0; + do { + x = + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp * Math.random(); + attemps++; + } while ( + Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 && + attemps < 10 + ); + + makeParticle( + gameState, + x, + gameState.gameZoneHeight, + (Math.random() - 0.5) * 10, + -5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + if (gameState.perks.streak_shots) { + const pos = 0.5 - Math.random(); + makeParticle( + gameState, + gameState.puckPosition + gameState.puckWidth * pos, + gameState.gameZoneHeight - gameState.puckHeight, + pos * 10, + -5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + } + + forEachLiveOne(gameState.particles, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.particles, pi); + } + }); + forEachLiveOne(gameState.texts, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.texts, pi); + } + }); + forEachLiveOne(gameState.lights, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.lights, pi); + } + }); } export function ballTick(gameState: GameState, ball: Ball, delta: number) { - ball.previousVX = ball.vx; - ball.previousVY = ball.vy; + ball.previousVX = ball.vx; + ball.previousVY = ball.vy; - let speedLimitDampener = - 1 + - gameState.perks.telekinesis + - gameState.perks.ball_repulse_ball + - gameState.perks.puck_repulse_ball + - gameState.perks.ball_attract_ball; + let speedLimitDampener = + 1 + + gameState.perks.telekinesis + + gameState.perks.ball_repulse_ball + + gameState.perks.puck_repulse_ball + + gameState.perks.ball_attract_ball; - if (isTelekinesisActive(gameState, ball)) { - speedLimitDampener += 3; - ball.vx += - ((gameState.puckPosition - ball.x) / 1000) * - delta * - gameState.perks.telekinesis; - } - if (isYoyoActive(gameState, ball)) { - speedLimitDampener += 3; - ball.vx += - ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo; - } - if ( - ball.vx * ball.vx + ball.vy * ball.vy < - gameState.baseSpeed * gameState.baseSpeed * 2 - ) { - ball.vx *= 1 + 0.02 / speedLimitDampener; - ball.vy *= 1 + 0.02 / speedLimitDampener; - } else { - ball.vx *= 1 - 0.02 / speedLimitDampener; - ball.vy *= 1 - 0.02 / speedLimitDampener; - } - // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract - if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) { - ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; - } + if (isTelekinesisActive(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += + ((gameState.puckPosition - ball.x) / 1000) * + delta * + gameState.perks.telekinesis; + } + if (isYoyoActive(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += + ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo; + } + if ( + ball.vx * ball.vx + ball.vy * ball.vy < + gameState.baseSpeed * gameState.baseSpeed * 2 + ) { + ball.vx *= 1 + 0.02 / speedLimitDampener; + ball.vy *= 1 + 0.02 / speedLimitDampener; + } else { + ball.vx *= 1 - 0.02 / speedLimitDampener; + ball.vy *= 1 - 0.02 / speedLimitDampener; + } + // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract + if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) { + ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; + } - if (gameState.perks.ball_repulse_ball) { - for (let b2 of gameState.balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true); - } + if (gameState.perks.ball_repulse_ball) { + for (let b2 of gameState.balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true); } - if (gameState.perks.ball_attract_ball) { - for (let b2 of gameState.balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - attract(gameState, ball, b2, gameState.perks.ball_attract_ball); - } + } + if (gameState.perks.ball_attract_ball) { + for (let b2 of gameState.balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + attract(gameState, ball, b2, gameState.perks.ball_attract_ball); } - if ( - gameState.perks.puck_repulse_ball && - Math.abs(ball.x - gameState.puckPosition) < - gameState.puckWidth / 2 + + } + if ( + gameState.perks.puck_repulse_ball && + Math.abs(ball.x - gameState.puckPosition) < + gameState.puckWidth / 2 + (gameState.ballSize * (9 + gameState.perks.puck_repulse_ball)) / 10 - ) { - repulse( - gameState, - ball, - { - x: gameState.puckPosition, - y: gameState.gameZoneHeight, - }, - gameState.perks.puck_repulse_ball + 1, - false, - ); - } - - 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; - - makeParticle( - gameState, - brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2, - brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2, - vertical ? 0 : -dx * gameState.baseSpeed, - vertical ? -dy * gameState.baseSpeed : 0, - color, - true, - gameState.coinSize / 2, - 250, - ); - } - } - - const borderHitCode = bordersHitCheck( - gameState, - ball, - gameState.ballSize / 2, - delta, + ) { + repulse( + gameState, + ball, + { + x: gameState.puckPosition, + y: gameState.gameZoneHeight, + }, + gameState.perks.puck_repulse_ball + 1, + false, ); - if (borderHitCode) { - if ( - gameState.perks.left_is_lava && - borderHitCode % 2 && - ball.x < gameState.offsetX + gameState.gameZoneWidth / 2 - ) { - resetCombo(gameState, ball.x, ball.y); - } + } - if ( - gameState.perks.right_is_lava && - borderHitCode % 2 && - ball.x > gameState.offsetX + gameState.gameZoneWidth / 2 - ) { - resetCombo(gameState, ball.x, ball.y); - } - - if (gameState.perks.top_is_lava && borderHitCode >= 2) { - resetCombo(gameState, ball.x, ball.y + gameState.ballSize); - } - if (gameState.perks.trampoline && borderHitCode >= 2) { - decreaseCombo( - gameState, - gameState.perks.trampoline, - ball.x, - ball.y + gameState.ballSize, - ); - } - - schedulGameSound(gameState, "wallBeep", ball.x, 1); - gameState.levelWallBounces++; - gameState.runStatistics.wall_bounces++; - } - - // Puck collision - const ylimit = - gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2; - const ballIsUnderPuck = - Math.abs(ball.x - gameState.puckPosition) < - gameState.ballSize / 2 + gameState.puckWidth / 2; - if ( - ball.y > ylimit && - ball.vy > 0 && - (ballIsUnderPuck || - (gameState.perks.extra_life && - ball.y > ylimit + gameState.puckHeight / 2)) + if ( + gameState.perks.respawn && + ball.hitItem?.length > 1 && + !isOptionOn("basic") + ) { + for ( + let i = 0; + i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; + i++ ) { - if (ballIsUnderPuck) { - const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); - const angle = Math.atan2( - -gameState.puckWidth / 2, - (ball.x - gameState.puckPosition) * - (gameState.perks.concave_puck ? -0.5 : 1), - ); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - schedulGameSound(gameState, "wallBeep", ball.x, 1); - } else { - ball.vy *= -1; + 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.perks.extra_life -= 1; - if (gameState.perks.extra_life < 0) { - gameState.perks.extra_life = 0; - } else if (gameState.perks.sacrifice) { - gameState.bricks.forEach((color, index) => color && explodeBrick( - gameState, - index, - ball, - true, - )) - } - - schedulGameSound(gameState, "lifeLost", ball.x, 1); - if (!isOptionOn("basic")) { - for (let i = 0; i < 10; i++) - makeParticle( - gameState, - ball.x, - ball.y, - Math.random() * gameState.baseSpeed * 3, - gameState.baseSpeed * 3, - "red", - false, - gameState.coinSize / 2, - 150, - ); - } - } - if (gameState.perks.streak_shots) { - resetCombo(gameState, ball.x, ball.y); - } - if (gameState.perks.trampoline) { - gameState.combo += gameState.perks.trampoline; - } - if ( - gameState.perks.nbricks && - ball.brokenSinceBounce < gameState.perks.nbricks - ) { - resetCombo(gameState, ball.x, ball.y); - } - - if (gameState.perks.respawn) { - ball.hitItem - .slice(0, -1) - .slice(0, gameState.perks.respawn) - .forEach(({index, color}) => { - if (!gameState.bricks[index] && color !== "black") { - // respawns with full hp - setBrick(gameState, index, color); - } - // gameState.bricks[index] = color; - }); - } - ball.hitItem = []; - if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) { - gameState.runStatistics.misses++; - if (gameState.perks.forgiving) { - const loss = Math.floor( - (gameState.levelMisses / 10) * - (gameState.combo - baseCombo(gameState)), - ); - decreaseCombo(gameState, loss, ball.x, ball.y - gameState.ballSize); - } else { - resetCombo(gameState, ball.x, ball.y); - } - gameState.levelMisses++; - makeText( - gameState, - gameState.puckPosition, - gameState.gameZoneHeight - gameState.puckHeight * 2, - "red", - t("play.missed_ball"), - gameState.puckHeight, - 500, - ); - } - gameState.runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.brokenSinceBounce = 0; - ball.sapperUses = 0; - ball.piercePoints = gameState.perks.pierce * 3; + makeParticle( + gameState, + brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2, + brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2, + vertical ? 0 : -dx * gameState.baseSpeed, + vertical ? -dy * gameState.baseSpeed : 0, + color, + true, + gameState.coinSize / 2, + 250, + ); } + } - const lostOnSides = - (gameState.perks.unbounded && ball.x < -gameState.gameZoneWidth / 2) || - ball.x > gameState.canvasWidth + gameState.gameZoneWidth / 2; + const borderHitCode = bordersHitCheck( + gameState, + ball, + gameState.ballSize / 2, + delta, + ); + if (borderHitCode) { if ( - gameState.running && - (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides) + gameState.perks.left_is_lava && + borderHitCode % 2 && + ball.x < gameState.offsetX + gameState.gameZoneWidth / 2 ) { - ball.destroyed = true; - gameState.runStatistics.balls_lost++; - if (!gameState.balls.find((b) => !b.destroyed)) { - gameOver( - t("gameOver.lost.title"), - t("gameOver.lost.summary", {score: gameState.score}), - ); - } + resetCombo(gameState, ball.x, ball.y); } - const radius = gameState.ballSize / 2; - // Make ball/coin bonce, and return bricks that were hit - const {x, y, previousX, previousY} = ball; - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; + if ( + gameState.perks.right_is_lava && + borderHitCode % 2 && + ball.x > gameState.offsetX + gameState.gameZoneWidth / 2 + ) { + resetCombo(gameState, ball.x, ball.y); + } - const hitBrick = vhit ?? hhit ?? chit; + if (gameState.perks.top_is_lava && borderHitCode >= 2) { + resetCombo(gameState, ball.x, ball.y + gameState.ballSize); + } + if (gameState.perks.trampoline && borderHitCode >= 2) { + decreaseCombo( + gameState, + gameState.perks.trampoline, + ball.x, + ball.y + gameState.ballSize, + ); + } - if (typeof hitBrick !== "undefined") { - ball.hitSinceBounce++; - let pierce = false; - let damage = - 1 + - (shouldPierceByColor(gameState, vhit, hhit, chit) - ? gameState.perks.pierce_color - : 0); + schedulGameSound(gameState, "wallBeep", ball.x, 1); + gameState.levelWallBounces++; + gameState.runStatistics.wall_bounces++; + } - gameState.brickHP[hitBrick] -= damage; + // Puck collision + const ylimit = + gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2; + const ballIsUnderPuck = + Math.abs(ball.x - gameState.puckPosition) < + gameState.ballSize / 2 + gameState.puckWidth / 2; + if ( + ball.y > ylimit && + ball.vy > 0 && + (ballIsUnderPuck || + (gameState.perks.extra_life && + ball.y > ylimit + gameState.puckHeight / 2)) + ) { + if (ballIsUnderPuck) { + const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); + const angle = Math.atan2( + -gameState.puckWidth / 2, + (ball.x - gameState.puckPosition) * + (gameState.perks.concave_puck ? -0.5 : 1), + ); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + schedulGameSound(gameState, "wallBeep", ball.x, 1); + } else { + ball.vy *= -1; - const used = Math.min( - ball.piercePoints, - Math.max(1, gameState.brickHP[hitBrick]), + gameState.perks.extra_life -= 1; + if (gameState.perks.extra_life < 0) { + gameState.perks.extra_life = 0; + } else if (gameState.perks.sacrifice) { + gameState.bricks.forEach( + (color, index) => color && explodeBrick(gameState, index, ball, true), ); - gameState.brickHP[hitBrick] -= used; - ball.piercePoints -= used; + } - if (gameState.brickHP[hitBrick] < 0) { - gameState.brickHP[hitBrick] = 0; - pierce = true; - } - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.y = ball.previousY; - ball.vy *= -1; - } - } - if (typeof hhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.x = ball.previousX; - ball.vx *= -1; - } - } - - if (!gameState.brickHP[hitBrick]) { - const initialBrickColor = gameState.bricks[hitBrick]; - ball.brokenSinceBounce++; - - explodeBrick(gameState, hitBrick, ball, false); - if ( - ball.sapperUses < gameState.perks.sapper && - initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !gameState.bricks[hitBrick] - ) { - setBrick(gameState, hitBrick, "black"); - ball.sapperUses++; - } - } + schedulGameSound(gameState, "lifeLost", ball.x, 1); + if (!isOptionOn("basic")) { + for (let i = 0; i < 10; i++) + makeParticle( + gameState, + ball.x, + ball.y, + Math.random() * gameState.baseSpeed * 3, + gameState.baseSpeed * 3, + "red", + false, + gameState.coinSize / 2, + 150, + ); + } + } + if (gameState.perks.streak_shots) { + resetCombo(gameState, ball.x, ball.y); + } + if (gameState.perks.trampoline) { + gameState.combo += gameState.perks.trampoline; + } + if ( + gameState.perks.nbricks && + ball.brokenSinceBounce < gameState.perks.nbricks + ) { + resetCombo(gameState, ball.x, ball.y); } - if (!isOptionOn("basic")) { - const remainingPierce = ball.piercePoints; - const remainingSapper = ball.sapperUses < gameState.perks.sapper; - const extraCombo = gameState.combo - 1; - if ( - (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) || - (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) || - (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) - ) { - const color = remainingSapper - ? Math.random() > 0.5 - ? "orange" - : "red" - : gameState.ballsColor; - - makeParticle( - gameState, - ball.x, - ball.y, - gameState.perks.pierce_color || remainingPierce - ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 - : (Math.random() - 0.5) * gameState.baseSpeed, - gameState.perks.pierce_color || remainingPierce - ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 - : (Math.random() - 0.5) * gameState.baseSpeed, - color, - true, - gameState.coinSize / 2, - 100, - ); - } + if (gameState.perks.respawn) { + ball.hitItem + .slice(0, -1) + .slice(0, gameState.perks.respawn) + .forEach(({ index, color }) => { + if (!gameState.bricks[index] && color !== "black") { + // respawns with full hp + setBrick(gameState, index, color); + } + // gameState.bricks[index] = color; + }); } + ball.hitItem = []; + if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) { + gameState.runStatistics.misses++; + if (gameState.perks.forgiving) { + const loss = Math.floor( + (gameState.levelMisses / 10) * + (gameState.combo - baseCombo(gameState)), + ); + decreaseCombo(gameState, loss, ball.x, ball.y - gameState.ballSize); + } else { + resetCombo(gameState, ball.x, ball.y); + } + gameState.levelMisses++; + makeText( + gameState, + gameState.puckPosition, + gameState.gameZoneHeight - gameState.puckHeight * 2, + "red", + t("play.missed_ball"), + gameState.puckHeight, + 500, + ); + } + gameState.runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.brokenSinceBounce = 0; + ball.sapperUses = 0; + ball.piercePoints = gameState.perks.pierce * 3; + } + + const lostOnSides = + (gameState.perks.unbounded && ball.x < -gameState.gameZoneWidth / 2) || + ball.x > gameState.canvasWidth + gameState.gameZoneWidth / 2; + if ( + gameState.running && + (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides) + ) { + ball.destroyed = true; + gameState.runStatistics.balls_lost++; + if (!gameState.balls.find((b) => !b.destroyed)) { + gameOver( + t("gameOver.lost.title"), + t("gameOver.lost.summary", { score: gameState.score }), + ); + } + } + const radius = gameState.ballSize / 2; + // Make ball/coin bonce, and return bricks that were hit + const { x, y, previousX, previousY } = ball; + + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; + + const hitBrick = vhit ?? hhit ?? chit; + + if (typeof hitBrick !== "undefined") { + ball.hitSinceBounce++; + let pierce = false; + let damage = + 1 + + (shouldPierceByColor(gameState, vhit, hhit, chit) + ? gameState.perks.pierce_color + : 0); + + gameState.brickHP[hitBrick] -= damage; + + const used = Math.min( + ball.piercePoints, + Math.max(1, gameState.brickHP[hitBrick]), + ); + gameState.brickHP[hitBrick] -= used; + ball.piercePoints -= used; + + if (gameState.brickHP[hitBrick] < 0) { + gameState.brickHP[hitBrick] = 0; + pierce = true; + } + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.y = ball.previousY; + ball.vy *= -1; + } + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.x = ball.previousX; + ball.vx *= -1; + } + } + + if (!gameState.brickHP[hitBrick]) { + const initialBrickColor = gameState.bricks[hitBrick]; + ball.brokenSinceBounce++; + + explodeBrick(gameState, hitBrick, ball, false); + if ( + ball.sapperUses < gameState.perks.sapper && + initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks + !gameState.bricks[hitBrick] + ) { + setBrick(gameState, hitBrick, "black"); + ball.sapperUses++; + } + } + } + + if (!isOptionOn("basic")) { + const remainingPierce = ball.piercePoints; + const remainingSapper = ball.sapperUses < gameState.perks.sapper; + const extraCombo = gameState.combo - 1; + if ( + (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) || + (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) || + (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) + ) { + const color = remainingSapper + ? Math.random() > 0.5 + ? "orange" + : "red" + : gameState.ballsColor; + + makeParticle( + gameState, + ball.x, + ball.y, + gameState.perks.pierce_color || remainingPierce + ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 + : (Math.random() - 0.5) * gameState.baseSpeed, + gameState.perks.pierce_color || remainingPierce + ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 + : (Math.random() - 0.5) * gameState.baseSpeed, + color, + true, + gameState.coinSize / 2, + 100, + ); + } + } } function makeCoin( - gameState: GameState, - x: number, - y: number, - vx: number, - vy: number, - color = "gold", - points = 1, + gameState: GameState, + x: number, + y: number, + vx: number, + vy: number, + color = "gold", + points = 1, ) { - append(gameState.coins, (p: Partial) => { - p.x = x; - p.y = y; - p.size = gameState.coinSize; - p.previousX = x; - p.previousY = y; - p.vx = vx; - p.vy = vy; - // p.sx = 0; - // p.sy = 0; - p.color = color; - p.a = Math.random() * Math.PI * 2; - p.sa = Math.random() - 0.5; - p.weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01); - p.points = points; - }); + append(gameState.coins, (p: Partial) => { + p.x = x; + p.y = y; + p.size = gameState.coinSize; + p.previousX = x; + p.previousY = y; + p.vx = vx; + p.vy = vy; + // p.sx = 0; + // p.sy = 0; + p.color = color; + p.a = Math.random() * Math.PI * 2; + p.sa = Math.random() - 0.5; + p.weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01); + p.points = points; + }); } function makeParticle( - gameState: GameState, - x: number, - y: number, - vx: number, - vy: number, - color: colorString, - ethereal = false, - size = 8, - duration = 150, + gameState: GameState, + x: number, + y: number, + vx: number, + vy: number, + color: colorString, + ethereal = false, + size = 8, + duration = 150, ) { - append(gameState.particles, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.vx = vx; - p.vy = vy; - p.color = color; - p.size = size; - p.duration = duration; - p.ethereal = ethereal; - }); + append(gameState.particles, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.vx = vx; + p.vy = vy; + p.color = color; + p.size = size; + p.duration = duration; + p.ethereal = ethereal; + }); } function makeText( - gameState: GameState, - x: number, - y: number, - color: colorString, - text: string, - size = 20, - duration = 150, + gameState: GameState, + x: number, + y: number, + color: colorString, + text: string, + size = 20, + duration = 150, ) { - append(gameState.texts, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.color = color; - p.size = size; - p.duration = duration; - p.text = text; - }); + append(gameState.texts, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.color = color; + p.size = size; + p.duration = duration; + p.text = text; + }); } function makeLight( - gameState: GameState, - x: number, - y: number, - color: colorString, - size = 8, - duration = 150, + gameState: GameState, + x: number, + y: number, + color: colorString, + size = 8, + duration = 150, ) { - append(gameState.lights, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.color = color; - p.size = size; - p.duration = duration; - }); + append(gameState.lights, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.color = color; + p.size = size; + p.duration = duration; + }); } export function append( - where: ReusableArray, - makeItem: (match: Partial) => void, + where: ReusableArray, + makeItem: (match: Partial) => void, ) { - while ( - where.list[where.indexMin] && - !where.list[where.indexMin].destroyed && - where.indexMin < where.list.length - ) { - where.indexMin++; - } - if (where.indexMin < where.list.length) { - where.list[where.indexMin].destroyed = false; - makeItem(where.list[where.indexMin]); - where.indexMin++; - } else { - const p = {destroyed: false}; - makeItem(p); - where.list.push(p); - } - where.total++; + while ( + where.list[where.indexMin] && + !where.list[where.indexMin].destroyed && + where.indexMin < where.list.length + ) { + where.indexMin++; + } + if (where.indexMin < where.list.length) { + where.list[where.indexMin].destroyed = false; + makeItem(where.list[where.indexMin]); + where.indexMin++; + } else { + const p = { destroyed: false }; + makeItem(p); + where.list.push(p); + } + where.total++; } export function destroy(where: ReusableArray, index: number) { - if (where.list[index].destroyed) return; - where.list[index].destroyed = true; - where.indexMin = Math.min(where.indexMin, index); - where.total--; + if (where.list[index].destroyed) return; + where.list[index].destroyed = true; + where.indexMin = Math.min(where.indexMin, index); + where.total--; } export function liveCount(where: ReusableArray) { - return where.total; + return where.total; } export function empty(where: ReusableArray) { - where.total = 0; - where.indexMin = 0; - where.list.forEach((i) => (i.destroyed = true)); + where.total = 0; + where.indexMin = 0; + where.list.forEach((i) => (i.destroyed = true)); } export function forEachLiveOne( - where: ReusableArray, - cb: (t: T, index: number) => void, + where: ReusableArray, + cb: (t: T, index: number) => void, ) { - where.list.forEach((item: T, index: number) => { - if (item && !item.destroyed) { - cb(item, index); - } - }); + where.list.forEach((item: T, index: number) => { + if (item && !item.destroyed) { + cb(item, index); + } + }); } diff --git a/src/i18n/b71.babel b/src/i18n/b71.babel index d3058c6..df09cc3 100644 --- a/src/i18n/b71.babel +++ b/src/i18n/b71.babel @@ -963,7 +963,7 @@ fr-FR - false + true @@ -978,7 +978,7 @@ fr-FR - false + true @@ -1453,7 +1453,7 @@ fr-FR - false + true @@ -1468,7 +1468,7 @@ fr-FR - false + true @@ -1483,7 +1483,7 @@ fr-FR - false + true @@ -1498,7 +1498,7 @@ fr-FR - false + true @@ -1513,7 +1513,7 @@ fr-FR - false + true @@ -1528,7 +1528,7 @@ fr-FR - false + true @@ -1543,7 +1543,7 @@ fr-FR - false + true @@ -1558,7 +1558,7 @@ fr-FR - false + true @@ -1573,7 +1573,7 @@ fr-FR - false + true @@ -1588,7 +1588,7 @@ fr-FR - false + true @@ -1603,7 +1603,7 @@ fr-FR - false + true @@ -1618,7 +1618,7 @@ fr-FR - false + true @@ -1633,7 +1633,7 @@ fr-FR - false + true diff --git a/src/loadGameData.test.ts b/src/loadGameData.test.ts index ba799fc..589ea17 100644 --- a/src/loadGameData.test.ts +++ b/src/loadGameData.test.ts @@ -21,7 +21,7 @@ describe("json data checks", () => { .split("") .filter((b) => b !== "_" && b !== "black") .filter((a, b, c) => c.indexOf(a) === b); - return uniqueBricks.length > 5; + return uniqueBricks.length > 5 && !l.name.startsWith("icon:"); }) .map((l) => l.name); expect(levelsWithManyBrickColors).toEqual([]); diff --git a/src/newGameState.ts b/src/newGameState.ts index 8b8373a..23d186d 100644 --- a/src/newGameState.ts +++ b/src/newGameState.ts @@ -28,7 +28,7 @@ export function newGameState(params: RunParams): GameState { const perks = { ...makeEmptyPerksMap(upgrades), ...(params?.perks || {}) }; - const gameState: GameState= { + const gameState: GameState = { runLevels, currentLevel: 0, upgradesOfferedFor: -1, @@ -101,10 +101,10 @@ export function newGameState(params: RunParams): GameState { autoCleanUses: 0, ...defaultSounds(), - isAdventureMode:!!params?.adventure, - adventurePath:'', - seed:'Seed'+Math.random(), - rerolls:0 + isAdventureMode: !!params?.adventure, + adventurePath: "", + seed: "Seed" + Math.random(), + rerolls: 0, }; resetBalls(gameState); diff --git a/src/premium.ts b/src/premium.ts index 6ce44aa..099d4c3 100644 --- a/src/premium.ts +++ b/src/premium.ts @@ -1,9 +1,9 @@ -import {GameState} from "./types"; -import {icons} from "./loadGameData"; -import {t} from "./i18n/i18n"; -import {getSettingValue, setSettingValue} from "./settings"; -import {asyncAlert} from "./asyncAlert"; -import {confirmRestart, openMainMenu, restart} from "./game"; +import { GameState } from "./types"; +import { icons } from "./loadGameData"; +import { t } from "./i18n/i18n"; +import { getSettingValue, setSettingValue } from "./settings"; +import { asyncAlert } from "./asyncAlert"; +import { confirmRestart, openMainMenu, restart } from "./game"; const publicKeyString = `-----BEGIN PUBLIC KEY----- MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAyGQJgs6gxa0Fd86TuZ2q @@ -18,144 +18,148 @@ RCASTIjXW61E7PQKir5qIXwkQDlzJ+bpZ3PHyAvspRrBaDxIYvEEw14evpuqOgS+ v/IlgPe+CWSvZa9xxnQl/aWZrOrD7syu6KKCbgUyXEm+Alp0YT3e6nwjn0qiM/cj dZpWPx3O+rZbRQb0gHcvN4+n2Y7fWAeC9mxVZtADqvVr/GTumMbLj7DdhWtt1Ogu 4EcvkQ5SKCL0JC93DyctjOMCAwEAAQ== ------END PUBLIC KEY-----` - +-----END PUBLIC KEY-----`; function pemToArrayBuffer(pem: string) { - const b64 = pem - .replace(/-----BEGIN PUBLIC KEY-----/, '') - .replace(/-----END PUBLIC KEY-----/, '') - .replace(/\s+/g, ''); - const binaryDerString = atob(b64); - const binaryDer = new Uint8Array(binaryDerString.length); - for (let i = 0; i < binaryDerString.length; i++) { - binaryDer[i] = binaryDerString.charCodeAt(i); - } - return binaryDer.buffer; + const b64 = pem + .replace(/-----BEGIN PUBLIC KEY-----/, "") + .replace(/-----END PUBLIC KEY-----/, "") + .replace(/\s+/g, ""); + const binaryDerString = atob(b64); + const binaryDer = new Uint8Array(binaryDerString.length); + for (let i = 0; i < binaryDerString.length; i++) { + binaryDer[i] = binaryDerString.charCodeAt(i); + } + return binaryDer.buffer; } async function getPriceId(key: string, pem: string) { - // Split the key into its components - const [priceId, timestamp, signature] = key.split(':'); - const data = `${priceId}:${timestamp}`; + // Split the key into its components + const [priceId, timestamp, signature] = key.split(":"); + const data = `${priceId}:${timestamp}`; - const publicKeyBuffer = pemToArrayBuffer(pem); + const publicKeyBuffer = pemToArrayBuffer(pem); + const publicKey = await crypto.subtle.importKey( + "spki", + publicKeyBuffer, + { + name: "RSA-PSS", + hash: "SHA-256", + }, + true, + ["verify"], + ); - const publicKey = await crypto.subtle.importKey( - 'spki', - publicKeyBuffer, - { - name: 'RSA-PSS', - hash: 'SHA-256', - }, - true, - ['verify'] - ); + // Verify the signature using ECDSA + const isValid = await crypto.subtle.verify( + { + name: "RSA-PSS", + saltLength: 32, + }, + publicKey, + new Uint8Array(Array.from(atob(signature), (c) => c.charCodeAt(0))), + new TextEncoder().encode(data), + ); + if (!isValid) throw new Error("Invalid key signature"); - - // Verify the signature using ECDSA - const isValid = await crypto.subtle.verify( - { - name: 'RSA-PSS', - saltLength: 32, - }, - publicKey, - new Uint8Array(Array.from(atob(signature), c => c.charCodeAt(0))), - new TextEncoder().encode(data) - ); - if (!isValid) throw new Error("Invalid key signature") - - return priceId; + return priceId; } -let premium = false -const gamePriceId = 'price_1R6YaEGRf74lr2EkSo2GPvuO' -checkKey(getSettingValue('license', '')).then() +let premium = false; +const gamePriceId = "price_1R6YaEGRf74lr2EkSo2GPvuO"; +checkKey(getSettingValue("license", "")).then(); async function checkKey(key: string) { - if (!key) return 'No key' - try { - - if (gamePriceId !== await getPriceId(key, publicKeyString)) { - return 'Wrong product' - } - premium = true - return '' - - } catch (e) { - return 'Could not upgrade : ' + e.message + if (!key) return "No key"; + try { + if (gamePriceId !== (await getPriceId(key, publicKeyString))) { + return "Wrong product"; } - + premium = true; + return ""; + } catch (e) { + return "Could not upgrade : " + e.message; + } } export function isPremium() { - return premium + return premium; } export function premiumMenuEntry(gameState: GameState) { - if (isPremium()) { - return { - icon: icons["icon:adventure_mode"], - text: t("premium.adventure_mode"), - help: t("premium.adventure_mode_help"), - value: async () => { - if (await confirmRestart(gameState)) { - restart({ - adventure: true - }) - } - - }, - } - } + if (isPremium()) { return { - icon: icons["icon:premium"], - text: t("premium.title"), - help: t("premium.short_help"), - value: () => openPremiumMenu(''), - } + icon: icons["icon:adventure_mode"], + text: t("premium.adventure_mode"), + help: t("premium.adventure_mode_help"), + value: async () => { + if (await confirmRestart(gameState)) { + restart({ + adventure: true, + }); + } + }, + }; + } + return { + icon: icons["icon:premium"], + text: t("premium.title"), + help: t("premium.short_help"), + value: () => openPremiumMenu(""), + }; } async function openPremiumMenu(text) { - const isGooglePlayInstall = new URLSearchParams(location.search).get('source') === 'com.android.vending' - - const cb = await asyncAlert({ - title: t("premium.title"), - text: text || (isGooglePlayInstall && t("premium.help_google")) || t("premium.help"), - actions: [ - { - text: t("premium.buy"), - disabled: isGooglePlayInstall, - help: isGooglePlayInstall ? t("premium.buy_disabled_help") : t("premium.buy_help"), - value() { - window.open('https://licenses.lecaro.me/buy/price_1R6YaEGRf74lr2EkSo2GPvuO', '_blank'); - } - }, - { - text: t("premium.enter"), - help: t("premium.enter_help"), - async value() { - const value = (prompt('Please paste your license key') || '').replace(/\s+/g, '') - const problem = await checkKey(value) - if (problem) { - openPremiumMenu(problem).then() - } else { - setSettingValue('license', value) - openMainMenu().then() - } - } - }, - { - text: t("premium.back"), - help: t("premium.back_help"), - value() { - openMainMenu().then() - } - } - - ] - }) - if (cb) cb() + const isGooglePlayInstall = + new URLSearchParams(location.search).get("source") === + "com.android.vending"; + const cb = await asyncAlert({ + title: t("premium.title"), + text: + text || + (isGooglePlayInstall && t("premium.help_google")) || + t("premium.help"), + actions: [ + { + text: t("premium.buy"), + disabled: isGooglePlayInstall, + help: isGooglePlayInstall + ? t("premium.buy_disabled_help") + : t("premium.buy_help"), + value() { + window.open( + "https://licenses.lecaro.me/buy/price_1R6YaEGRf74lr2EkSo2GPvuO", + "_blank", + ); + }, + }, + { + text: t("premium.enter"), + help: t("premium.enter_help"), + async value() { + const value = (prompt("Please paste your license key") || "").replace( + /\s+/g, + "", + ); + const problem = await checkKey(value); + if (problem) { + openPremiumMenu(problem).then(); + } else { + setSettingValue("license", value); + openMainMenu().then(); + } + }, + }, + { + text: t("premium.back"), + help: t("premium.back_help"), + value() { + openMainMenu().then(); + }, + }, + ], + }); + if (cb) cb(); } diff --git a/src/render.ts b/src/render.ts index 39efb59..e0ba7ed 100644 --- a/src/render.ts +++ b/src/render.ts @@ -538,7 +538,7 @@ export function renderAllBricks() { let redBecauseOfReach = gameState.perks.reach && countBricksAbove(gameState, index) && - !countBricksBelow(gameState, index) ; + !countBricksBelow(gameState, index); let redBorder = (gameState.ballsColor !== color && diff --git a/src/types.d.ts b/src/types.d.ts index eb44b66..0e51512 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -266,17 +266,17 @@ export type GameState = { coinCatch: { vol: number; x: number }; colorChange: { vol: number; x: number }; }; - isAdventureMode:boolean; - adventurePath:string; - seed:string; - rerolls:number; + isAdventureMode: boolean; + adventurePath: string; + seed: string; + rerolls: number; }; export type RunParams = { level?: string; levelToAvoid?: string; perks?: Partial; - adventure?:boolean; + adventure?: boolean; }; export type OptionDef = { default: boolean;