From 504fd6649cf43c448437459f491de42f7eae57ef Mon Sep 17 00:00:00 2001 From: Renan LE CARO Date: Fri, 7 Mar 2025 20:18:18 +0100 Subject: [PATCH] Creative mode, cleanup loop fix --- Readme.md | 50 +- dist/index.html | 292 ++- src/game.ts | 5247 +++++++++++++++++++++++--------------------- src/levels.json | 12 +- src/options.ts | 96 +- src/rawUpgrades.ts | 29 +- src/style.css | 142 +- src/types.d.ts | 222 +- 8 files changed, 3189 insertions(+), 2901 deletions(-) diff --git a/Readme.md b/Readme.md index 028f516..876ae94 100644 --- a/Readme.md +++ b/Readme.md @@ -38,7 +38,6 @@ quickly destroyed again. # Game engine features - the onboarding feels weird, missing a tutorial -- Players can't choose the initial perk - apk version soft locks at start. - shinier coins by applying glow to them ? - ask for permanent storage @@ -124,6 +123,28 @@ quickly destroyed again. - gravity is flipped on the opposite side to the puck (for coins) - balls have gravity - coins don't have gravity +- [colin] yoyo - when the ball falls back down, it curbs towards your puck (after hitting a brick or top) +- [colin] single block combo - get +1 combo if the ball only breaks a single block before reaching the puck +- [colin] mirror puck - a mirrored puck at the top of the screen follows as you move the bottom puck. it helps with keeping combos up and preventing the ball from touching the ceiling. it could appear as a hollow puck so as to not draw too much attention from the main bottom puck. +- [colin] side pucks - same as above but with two side pucks. +- [colin] ball coins - coins share the same physics as coins and bounce on walls and bricks +- [colin] phantom coins - coins pass through bricks +- [colin] drifting coins - coins slowly drift away from the brick they were generated from, and they need to be collected by the ball +- [colin] bigger ball - self-explanatory +- [colin] smaller ball - yes. +- [colin] sturdy ball - does more damage to bricks, to conter sturdy bricks +- [colin] accumulation - coins aglutinate into bigger coins that hold more value +- [colin] forgiving - you can miss several times without losing your combo. or alternatively, include this ability into the soft reset perk. +- [colin] plot - plot the ball's trajectory as you position your puck +- [colin] golden corners - catch coins at the sides of the puck to double their value +- [colin] varied diet - your combo grows if you keep hitting different coloured bricks each time +- [colin] earthquake - when the puck hits any side of the screen with velocity, the screen shakes and a brick explodes/falls from the level. alternatively, any brick you catch with the puck gives you the coins at the current combo rate. each level lowers the amount of hits before a brick falls +- [colin] statue - stand still to make the combo grow. move for too long and thi combo will quickly drop +- [colin] piggy bank - bricks absorb coins that fall onto it, and release them back as they are broken, with added value +- [colin] trickle up - if you first hit is the lowest brick of a column, all bricks above get +1 coin inside +- [colin] wormhole - the puck sometimes don't bounce the ball back up but teleports it to the top of the screen as if it fell through from bottom to top. higher levels reduce the times it takes to reload that effect +- [colin] hitman - hit the marked brick for +5 combo. each level increases the combo you get for it. +- [colin] sweet spot - place your puck directly below a moving spot at the top of the level to increase your combo # Balancing ideas @@ -164,32 +185,7 @@ I could unlock the "pro stand" at $999 that just holds the play area higher. # Colin's feedback (cwpute/obigre) - -Perks: - -* yoyo - when the ball falls back down, it curbs towards your puck (after hitting a brick or top) -* single block combo - get +1 combo if the ball only breaks a single block before reaching the puck -* mirror puck - a mirrored puck at the top of the screen follows as you move the bottom puck. it helps with keeping combos up and preventing the ball from touching the ceiling. it could appear as a hollow puck so as to not draw too much attention from the main bottom puck. -* side pucks - same as above but with two side pucks. -* ball coins - coins share the same physics as coins and bounce on walls and bricks -* phantom coins - coins pass through bricks -* drifting coins - coins slowly drift away from the brick they were generated from, and they need to be collected by the ball -* bigger ball - self-explanatory -* smaller ball - yes. -* sturdy ball - does more damage to bricks, to conter sturdy bricks -* accumulation - coins aglutinate into bigger coins that hold more value -* forgiving - you can miss several times without losing your combo. or alternatively, include this ability into the soft reset perk. -* plot - plot the ball's trajectory as you position your puck -* golden corners - catch coins at the sides of the puck to double their value -* varied diet - your combo grows if you keep hitting different coloured bricks each time -* earthquake - when the puck hits any side of the screen with velocity, the screen shakes and a brick explodes/falls from the level. alternatively, any brick you catch with the puck gives you the coins at the current combo rate. each level lowers the amount of hits before a brick falls -* statue - stand still to make the combo grow. move for too long and thi combo will quickly drop -* piggy bank - bricks absorb coins that fall onto it, and release them back as they are broken, with added value -* trickle up - if you first hit is the lowest brick of a column, all bricks above get +1 coin inside -* wormhole - the puck sometimes don't bounce the ball back up but teleports it to the top of the screen as if it fell through from bottom to top. higher levels reduce the times it takes to reload that effect -* hitman - hit the marked brick for +5 combo. each level increases the combo you get for it. -* sweet spot - place your puck directly below a moving spot at the top of the level to increase your combo - + IMPROVEMENTS ON EXISTING PERKS : * separate the "shoot straight" perk into two : one for left-side, the other for right-side. it will help alleviate the high difficulty of this challenge and provide more interesting ways to play around it. the wind perk could even find a use. diff --git a/dist/index.html b/dist/index.html index 3ce5320..cc36630 100644 --- a/dist/index.html +++ b/dist/index.html @@ -88,6 +88,15 @@ body { display: flex; } +.popup.actionsAsGrid > div { + max-width: 800px; + + & section { + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + display: grid; + } +} + .popup > div > * { margin: 0; padding: 0; @@ -97,23 +106,55 @@ body { margin-bottom: 20px; } -.popup > div > button { - font: inherit; - color: #fff; - cursor: pointer; - text-align: left; - background: #000c; - border: 1px solid #fff; - gap: 10px; - margin-top: -1px; - padding: 10px; +.popup > div > section { + flex-direction: column; + align-items: stretch; + margin-top: 20px; display: flex; -} -.popup > div > button:not([disabled]):hover, .popup > div > button:not([disabled]):focus { - z-index: 1; - border-color: #f1d33b; - position: relative; + & button { + font: inherit; + color: #fff; + cursor: pointer; + text-align: left; + background: #000c; + border: 1px solid #fff; + gap: 10px; + margin-top: -1px; + padding: 10px; + display: flex; + + &:not([disabled]):hover, &:not([disabled]):focus { + z-index: 1; + border-color: #f1d33b; + position: relative; + } + + &[disabled] { + opacity: .5; + filter: saturate(0); + pointer-events: none; + } + + & > div { + flex-grow: 1; + } + + & > div > em { + opacity: .8; + display: block; + } + + &.grey-out-unless-hovered { + &:not(:hover) { + opacity: .6; + + & img { + filter: saturate(0); + } + } + } + } } .popup button.close-modale { @@ -127,57 +168,21 @@ body { top: 0; right: 0; overflow: hidden; -} -.popup button.close-modale:before { - content: "+"; - font-size: 80px; - display: inline-block; - position: absolute; - top: 34px; - left: 26px; - transform: translate(-50%, -50%)rotate(45deg); -} + &:before { + content: "+"; + font-size: 80px; + display: inline-block; + position: absolute; + top: 34px; + left: 26px; + transform: translate(-50%, -50%)rotate(45deg); + } -.popup button.close-modale:hover { - background: #000; - font-weight: bold; -} - -.popup > div > button[disabled] { - opacity: .5; - filter: saturate(0); - pointer-events: none; -} - -.popup > div > button > div { - flex-grow: 1; -} - -.popup > div > button > div > em { - opacity: .8; - display: block; -} - -.popup > div > button > span.checks { - flex-grow: 0; - flex-shrink: 0; - gap: 5px; - width: 40px; - height: 40px; - display: inline-flex; -} - -.popup > div > button > span.checks > span { - opacity: .1; - background: #fff; - border-radius: 4px; - flex: 10px; - align-self: stretch; -} - -.popup > div > button > span.checks > span.checked { - opacity: 1; + &:hover { + background: #000; + font-weight: bold; + } } .popup .textAfterButtons { @@ -1094,7 +1099,7 @@ function addToScore(coin) { coin.destroyed = true; score += coin.points; addToTotalScore(coin.points); - if (score > highScore) { + if (score > highScore && !ignoreThisRunInStats) { highScore = score; localStorage.setItem("breakout-3-hs", score.toString()); } @@ -1123,6 +1128,7 @@ function resetBalls() { const perBall = puckWidth / (count + 1); balls = []; ballsColor = "#FFF"; + if (perks.picky_eater || perks.pierce_color) ballsColor = getMajorityValue(bricks.filter((i)=>i)) || '#FFF'; for(let i = 0; i < count; i++){ const x = puck - puckWidth / 2 + perBall * (i + 1); balls.push({ @@ -1234,6 +1240,7 @@ function setLevel(l) { if (l > 0) openUpgradesPicker().then(); currentLevel = l; levelTime = 0; + level_skip_last_uses = 0; lastTickDown = levelTime; levelStartScore = score; levelSpawnedCoins = 0; @@ -1315,11 +1322,13 @@ function pickRandomUpgrades(count) { })); } let nextRunOverrides = {}; +let ignoreThisRunInStats = false; let pauseUsesDuringRun = 0; function restart() { // When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next // run's level list totalScoreAtRunStart = getTotalScore(); + ignoreThisRunInStats = false; shuffleLevels(levelTime || score ? currentLevelInfo().name : null); resetRunStatistics(); score = 0; @@ -1489,9 +1498,12 @@ function tick() { lastTickDown = levelTime; decreaseCombo(perks.hot_start, puck, gameZoneHeight - 2 * puckHeight); } - if (remainingBricks <= perks.skip_last) bricks.forEach((type, index)=>{ - if (type) explodeBrick(index, balls[0], true); - }); + if (remainingBricks <= perks.skip_last && !level_skip_last_uses) { + bricks.forEach((type, index)=>{ + if (type) explodeBrick(index, balls[0], true); + }); + level_skip_last_uses++; + } if (!remainingBricks && !coins.length) { if (currentLevel + 1 < max_levels()) setLevel(currentLevel + 1); else gameOver("Run finished with " + score + " points", "You cleared all levels for this run."); @@ -1581,16 +1593,20 @@ function tick() { vx: (Math.random() - 0.5) * 10, vy: 5 }); - if (perks.sides_are_lava) { - const fromLeft = Math.random() > 0.5; - baseParticle && flashes.push({ - ...baseParticle, - x: offsetXRoundedDown + (fromLeft ? 0 : gameZoneWidthRoundedUp), - y: Math.random() * gameZoneHeight, - vx: fromLeft ? 5 : -5, - vy: (Math.random() - 0.5) * 10 - }); - } + if (perks.left_is_lava && baseParticle) flashes.push({ + ...baseParticle, + x: offsetXRoundedDown, + y: Math.random() * gameZoneHeight, + vx: 5, + vy: (Math.random() - 0.5) * 10 + }); + if (perks.right_is_lava && baseParticle) flashes.push({ + ...baseParticle, + x: offsetXRoundedDown + gameZoneWidthRoundedUp, + y: Math.random() * gameZoneHeight, + vx: -5, + vy: (Math.random() - 0.5) * 10 + }); if (perks.compound_interest) { let x = puck, attemps = 0; do { @@ -1677,7 +1693,8 @@ function ballTick(ball, delta) { } const borderHitCode = bordersHitCheck(ball, ballSize / 2, delta); if (borderHitCode) { - if (perks.sides_are_lava && borderHitCode % 2) resetCombo(ball.x, ball.y); + if (perks.left_is_lava && borderHitCode % 2 && ball.x < offsetX + gameZoneWidth / 2) resetCombo(ball.x, ball.y); + if (perks.right_is_lava && borderHitCode % 2 && ball.x > offsetX + gameZoneWidth / 2) resetCombo(ball.x, ball.y); if (perks.top_is_lava && borderHitCode >= 2) resetCombo(ball.x, ball.y + ballSize); sounds.wallBeep(ball.x); ball.bouncesList?.push({ @@ -1800,6 +1817,7 @@ function getTotalScore() { } } function addToTotalScore(points) { + if (ignoreThisRunInStats) return; try { localStorage.setItem("breakout_71_total_score", JSON.stringify(getTotalScore() + points)); } catch (e) {} @@ -1861,14 +1879,15 @@ function gameOver(title, intro) { allowClose: true, title, text: ` + ${ignoreThisRunInStats ? "

This test run and its score are not being recorded

" : ""}

${intro}

${unlocksInfo} `, actions: [ { value: null, - text: 'Start a new run', - help: '' + text: "Start a new run", + help: "" } ], textAfterButtons: `
@@ -1889,7 +1908,7 @@ function getHistograms() { appVersion: (0, _loadGameData.appVersion) }); // Generate some histogram - localStorage.setItem("breakout_71_runs_history", JSON.stringify(runsHistory, null, 2)); + if (!ignoreThisRunInStats) localStorage.setItem("breakout_71_runs_history", JSON.stringify(runsHistory, null, 2)); const makeHistogram = (title, getter, unit)=>{ let values = runsHistory.map((h)=>getter(h) || 0); let min = Math.min(...values); @@ -2032,7 +2051,7 @@ function explodeBrick(index, ball, isExplosion) { weight: 0.8 + Math.random() * 0.2 }); } - combo += Math.max(0, perks.streak_shots + perks.compound_interest + perks.sides_are_lava + perks.top_is_lava + perks.picky_eater - Math.round(Math.random() * perks.soft_reset)); + combo += Math.max(0, perks.streak_shots + perks.compound_interest + perks.left_is_lava + perks.right_is_lava + perks.top_is_lava + perks.picky_eater - Math.round(Math.random() * perks.soft_reset)); if (!isExplosion) { // color change if ((perks.picky_eater || perks.pierce_color) && color !== ballsColor && color) { @@ -2051,7 +2070,7 @@ function explodeBrick(index, ball, isExplosion) { }); spawnExplosion(5 + Math.min(combo, 30), x, y, color, 150, coinSize / 2); } - if (!bricks[index] && color !== 'black') ball.hitItem?.push({ + if (!bricks[index] && color !== "black") ball.hitItem?.push({ index, color }); @@ -2204,16 +2223,18 @@ function render() { } else drawText(ctx, comboText, "#FFF", puckHeight, puck, gameZoneHeight - puckHeight / 2, false); } // Borders - const redSides = perks.sides_are_lava && combo > baseCombo(); - ctx.fillStyle = redSides ? "red" : puckColor; + const hasCombo = combo > baseCombo(); ctx.globalCompositeOperation = "source-over"; if (offsetXRoundedDown) { // draw outside of gaming area to avoid capturing borders in recordings + ctx.fillStyle = hasCombo && perks.left_is_lava ? "red" : puckColor; ctx.fillRect(offsetX - 1, 0, 1, height); + ctx.fillStyle = hasCombo && perks.right_is_lava ? "red" : puckColor; ctx.fillRect(width - offsetX + 1, 0, 1, height); - } else if (redSides) { - ctx.fillRect(0, 0, 1, height); - ctx.fillRect(width - 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); } if (perks.top_is_lava && combo > baseCombo()) drawRedSquare(ctx, offsetXRoundedDown, 0, gameZoneWidthRoundedUp, 1); const redBottom = perks.compound_interest && combo > baseCombo(); @@ -2230,7 +2251,7 @@ let cachedBricksRenderKey = null; function renderAllBricks() { ctx.globalAlpha = 1; const redBorderOnBricksWithWrongColor = combo > baseCombo() && perks.picky_eater; - const newKey = gameZoneWidth + "_" + bricks.join("_") + bombSVG.complete + "_" + redBorderOnBricksWithWrongColor + "_" + ballsColor; + const newKey = gameZoneWidth + "_" + bricks.join("_") + bombSVG.complete + "_" + redBorderOnBricksWithWrongColor + "_" + ballsColor + '_' + perks.pierce_color; if (newKey !== cachedBricksRenderKey) { cachedBricksRenderKey = newKey; cachedBricksRender.width = gameZoneWidth; @@ -2243,7 +2264,8 @@ function renderAllBricks() { bricks.forEach((color, index)=>{ const x = brickCenterX(index), y = brickCenterY(index); if (!color) return; - const borderColor = ballsColor === color && puckColor || color !== "black" && redBorderOnBricksWithWrongColor && "red" || color; + canctx.globalAlpha = perks.pierce_color && ballsColor === color && 0.6 || 1; + const borderColor = ballsColor !== color && color !== "black" && redBorderOnBricksWithWrongColor && "red" || color; drawBrick(canctx, color, borderColor, x, y); if (color === "black") { canctx.globalCompositeOperation = "source-over"; @@ -2580,17 +2602,19 @@ function createExplosionSound(pan = 0.5) { noiseSource.stop(context.currentTime + 1); } let levelTime = 0; +// Limits skip last to one use per level +let level_skip_last_uses = 0; window.addEventListener("visibilitychange", ()=>{ if (document.hidden) pause(true); }); const scoreDisplay = document.getElementById("score"); let alertsOpen = 0, closeModal = null; -function asyncAlert({ title, text, actions, allowClose = true, textAfterButtons = "" }) { +function asyncAlert({ title, text, actions, allowClose = true, textAfterButtons = "", actionsAsGrid = false }) { alertsOpen++; return new Promise((resolve)=>{ const popupWrap = document.createElement("div"); document.body.appendChild(popupWrap); - popupWrap.className = "popup"; + popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); function closeWithResult(value) { resolve(value); // Doing this async lets the menu scroll persist if it's shown a second time @@ -2622,7 +2646,9 @@ function asyncAlert({ title, text, actions, allowClose = true, textAfterButtons p.innerHTML = text; popup.appendChild(p); } - actions.filter((i)=>i).forEach(({ text, value, help, disabled, icon = "" })=>{ + const buttons = document.createElement("section"); + popup.appendChild(buttons); + actions.filter((i)=>i).forEach(({ text, value, help, disabled, className = "", icon = "" })=>{ const button = document.createElement("button"); button.innerHTML = ` ${icon} @@ -2635,7 +2661,8 @@ ${icon} e.preventDefault(); closeWithResult(value); }); - popup.appendChild(button); + button.className = className; + buttons.appendChild(button); }); if (textAfterButtons) { const p = document.createElement("div"); @@ -2683,6 +2710,7 @@ async function openScorePanel() { const cb = await asyncAlert({ title: ` ${score} points at level ${currentLevel + 1} / ${max_levels()}`, text: ` + ${ignoreThisRunInStats ? "

This is a test run, score is not recorded permanently

" : ""}

Upgrades picked so far :

${pickedUpgradesHTMl()}

`, @@ -2721,6 +2749,7 @@ async function openSettingsPanel() { openSettingsPanel(); } }); + const creativeModeTreshold = Math.max(...(0, _loadGameData.upgrades).map((u)=>u.threshold)); const cb = await asyncAlert({ title: "Breakout 71", text: ` @@ -2755,6 +2784,39 @@ async function openSettingsPanel() { toggleFullScreen(); } }), + { + text: "Creative mode", + help: getTotalScore() < creativeModeTreshold ? "Unlocks at total score $" + creativeModeTreshold : "Test runs with custom perks", + disabled: getTotalScore() < creativeModeTreshold, + async value () { + let creativeModePerks = {}, choice; + while(choice = await asyncAlert({ + title: "Select perks", + text: 'Select perks below and press "start run" to try them out in a test run. Scores and stats are not recorded.', + actionsAsGrid: true, + actions: [ + ...(0, _loadGameData.upgrades).map((u)=>({ + icon: u.icon, + text: u.name, + help: (creativeModePerks[u.id] || 0) + "/" + u.max, + value: u, + className: creativeModePerks[u.id] ? "" : "grey-out-unless-hovered" + })), + { + text: "Start run", + value: "start" + } + ] + })){ + if (choice === "start") { + restart(); + ignoreThisRunInStats = true; + Object.assign(perks, creativeModePerks); + break; + } else if (choice) creativeModePerks[choice.id] = ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1); + } + } + }, { text: "Reset Game", help: "Erase high score and statistics", @@ -3034,7 +3096,7 @@ function stopRecording() { mediaRecorder?.stop(); mediaRecorder = null; } -function captureFileName(ext = 'webm') { +function captureFileName(ext = "webm") { return "breakout-71-capture-" + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + "." + ext; } function findLast(arr, predicate) { @@ -3085,6 +3147,15 @@ document.addEventListener("keyup", (e)=>{ else return; e.preventDefault(); }); +function sample(arr) { + return arr[Math.floor(arr.length * Math.random())]; +} +function getMajorityValue(arr) { + const count = {}; + arr.forEach((v)=>count[v] = (count[v] || 0) + 1); + const max = Math.max(...Object.values(count)); + return sample(Object.keys(count).filter((k)=>count[k] == max)); +} fitSize(); restart(); tick(); @@ -3172,7 +3243,7 @@ const upgrades = (0, _rawUpgrades.rawUpgrades).map((u)=>({ module.exports = JSON.parse("{\"_\":\"\",\"B\":\"black\",\"W\":\"white\",\"g\":\"#231f20\",\"y\":\"#ffd300\",\"b\":\"#6262EA\",\"t\":\"#5DA3EA\",\"s\":\"#E67070\",\"r\":\"#e32119\",\"R\":\"#ab0c0c\",\"c\":\"#59EEA3\",\"G\":\"#A1F051\",\"v\":\"#A664E8\",\"p\":\"#E869E8\",\"a\":\"#5BECEC\",\"C\":\"#53EE53\",\"S\":\"#F44848\",\"P\":\"#E66BA8\",\"O\":\"#F29E4A\",\"k\":\"#618227\",\"e\":\"#e1c8b4\",\"l\":\"#9b9fa4\"}"); },{}],"kqnNl":[function(require,module,exports,__globalThis) { -module.exports = JSON.parse('[{"name":"71 mini","size":5,"bricks":"bbb____bt__btt__b_t___ttt","svg":"","color":""},{"name":"Butterfly","bricks":"_________bb_t_t_bbbbb_t_bbbbbbbtbbbb_bbbtbbb____btb____bbbtbbb__bb_t_bb___________________","size":9,"svg":"","color":""},{"name":"Castle","size":7,"bricks":"s_s_s_ssssssssssBBBssssBBBssttbbbttttbbbtttbtbtbt","svg":""},{"name":"Eyes","size":9,"bricks":"ttttttt__tWWWWWWW_tWrrWttW_tWWWWWWW_ttttttt_____t______ttttt____ttttt_____t_t","svg":"","color":""},{"name":"Creeper","size":10,"bricks":"___________ccGGccGG__cGccGcGc__GBBccBBc__cBBGcBBc__GccBBGGc__ccBBBBcG__GGBBBBcG__cGBccBGc___________","svg":""},{"name":"Stairs","size":8,"bricks":"tt______tt______bbtt____bbtt____vvbbtt__vvbbtt__ppvvbbttppvvbbtt","svg":""},{"name":"Dots","size":9,"bricks":"b_t_a_c_C__________b_t_a_c__________v_b_t_a_c__________v_b_t_a__________p_v_b_t_a","svg":""},{"name":"Lines","size":9,"bricks":"aaaaaaaa___________tttttttt_________aaaaaaaa___________tttttttt_________aaaaaaaa","svg":"","color":""},{"name":"Heart","size":15,"bricks":"__________________RRR___RRR_____RSSSR_RSSSR___RSWWSSRSSSSSR__RSWSSSSSSSSSR__RSSSSSSSSSSSR__RSWSSSSSSSSSR___RSSSSSSSSSR_____RSSSSSSSR_______RSSSSSR_________RSSSR___________RSR_____________R____________________________________","svg":"","color":""},{"name":"Swiss","size":7,"bricks":"________RRRRR__RRWRR__RWWWR__RRWRR__RRRRR","svg":"","color":""},{"name":"Germany","size":6,"bricks":"_______gggg__rrrr__yyyy","svg":"","color":""},{"name":"France","size":8,"bricks":"_________ttWWrr__ttWWrr__ttWWrr__ttWWrr__ttWWrr________","svg":"","color":""},{"name":"Smiley","size":8,"bricks":"_________yy__yy__yy__yy__________________yyyyyy___yyyy__________","svg":"","color":""},{"name":"Labyrinthe","size":11,"bricks":"_______tttS_Stttt_S________t___S__Stt_ttttt____t_____S__ttt_S_S____t___t_tttt_t_S_t____tSt_t_t_Sttt___t_t_____Sttt_tttttS","svg":""},{"name":"Temple","size":11,"bricks":"_______________WWW______WWWWWWW___WWWWWWWWW___t_t_t_t____b_b_b_b____v_v_v_v____p_p_p_p____P_P_P_P____WWWWWWW___WWWWWWWWW_","svg":"","color":""},{"name":"Pacman","size":12,"bricks":"____yyyy______yyyyyyyy___yyyyByyyyy__yyyyyyyyy__yyyyyyyy____yyyyyy______yyyyyy___S_Syyyyyyyy_____yyyyyyyyy___yyyyyyyyyy___yyyyyyyy______yyyy","svg":"","color":""},{"name":"Ship","size":11,"bricks":"____sWW________sWWW_______sWWW_______s___OOOOOOOOOOOOOO_OBOBOBOBOO__OOOOOOOO_bbbbbbbbgbbbbgbbbbggbbbggbbbbbbbb","svg":""},{"name":"We come in peace","size":13,"bricks":"________________a_____a_______a___a_______aaaaaaa_____aaBaaaBaa___aaaaaaaaaaa__aaaaaaaaaaa__a_aaaaaaa_a__a_a_____a_a_____aa_aa_____________________________","svg":"","color":""},{"name":"Space mushroom","size":10,"bricks":"______________WW_______WWWW_____WWWWWW___WWBWWBWW__WWWWWWWW____W__W_____W_WW_W___W_W__W_W","svg":"","color":""},{"name":"Wololo","size":9,"bricks":"____WW_OOW___WW__OWW__W___OWWWbbbW_WWW_WbW_WOW__WWb__OW__bbb__O___W_W__O___W_W__O","svg":"","color":""},{"name":"Small heart","size":15,"bricks":"________________________________RRRR___RRRR___RrWWrR_RWWrrR__RWWrrrRWWrrrR__RrrrrrrrrrrrR__RrrrrrrrrrrrR___RrrrrrrrrrR_____RrrrrrrrR_______RrrrrrR_________RrrrR___________RrR_____________R______________________","svg":"","color":""},{"name":"Eye","size":9,"bricks":"____________ggg_____gWWWg___gWbbbWg_gWWbBbWWg_gWbbbWg___gWWWg_____ggg____________","svg":"","color":"#5da3ea"},{"name":"Enderman","size":10,"bricks":"___________gggggggg__gggggggg__gggggggg__gggggggg__vvvggvvv__gggggggg__gggggggg__gggggggg_____________________","svg":"","color":"#ffffff"},{"name":"Mushroom","size":16,"bricks":"_____________________rrrrWW________WWrrrrWWWW_____WWrrrrrrWWWW____WrrWWWWrrWWW___rrrWWWWWWrrrrr__rrrWWWWWWrrWWr__WrrWWWWWWrWWWW__WWrrWWWWrrWWWW__WWrrrrrrrrrWWr__WrrWWWWWWWWrrr_____WWBWWBWW_______WWWBWWBWWW______WWWWWWWWWW_______WWWWWWWW____________________","svg":"","color":""},{"name":"Tulip","size":11,"bricks":"______________R_R_R______RRRRR______RRRRR______RRRRR_______RRR_________k________k_k_k______k_k_k_______kkk_________k________________","svg":"","color":""},{"name":"Chain","size":7,"bricks":"yyy____yBy____yyyyy____yBy____yyyyy____yBy____yyy","svg":"","color":""},{"name":"Marion","size":9,"bricks":"rr_____rr_rr___rr__rrr_rrr__rrrrrrr__rr_r_rr__rr___rr__rr___rr__rr___rr_rrr___rrr","svg":"","color":""},{"name":"Renan","size":9,"bricks":"yyyyyyy___yyyyyyy__yy___yy__yy___yy__yyyyyy___yy_yy____yy__yy___yy___yy_yyy___yyy","svg":"","color":""},{"name":"Violet Pairs","size":8,"bricks":"b_b_b_b_b_b_b_b__________t_t_t_t_t_t_t_t________b_b_b_b_b_b_b_b","svg":"","color":""},{"name":"Red Cups","size":11,"bricks":"___________rBr_rBr_rBrrrr_rrr_rrr___________r_rBr_rBr_rr_rrr_rrr_r___________rBr_rBr_rBrrrr_rrr_rrr__________","svg":"","color":""},{"name":"Cactus","size":10,"bricks":"____G______rG_Gk______G_Gk______kkkk_r_____kkk_G______GkGk_____rGkk_______Gk________kk________kk_____","svg":"","color":""},{"name":"Sunny Face","size":11,"bricks":"____yyy______yyyyyyy___yyyyyyyyy__yyyyyyyyy_yyyWWyWWyyyyyyyyyyyyyyyyyyyyyyyyy_yyWWWWWyy__yyyWWWyyy___yyyyyyy______yyy","svg":"","color":"#5da3ea"},{"name":"Mountain","size":9,"bricks":"_______________W_______WWW______GGWW__W_GGGGG_kkkGGGGG_kkkkGGGGkkkkkGGGGkkkkkkGGG_________","svg":"","color":""},{"name":"Dollar","size":17,"bricks":"________________________G_G______________G_G____________GGGGGGG_________GGGGGGGGG_______GG__G_G__GG______GG__G_G__GG______GG__G_G___________GGGGGGGG__________GGGGGGGG___________G_G__GG______GG__G_G__GG______GG__G_G__GG_______GGGGGGGGG_________GGGGGGG____________G_G______________G_G________________________","svg":"","color":""},{"name":"Waves","size":8,"bricks":"___bbb____bbb____bbttbbbbbttbbbbttttaatttttaattttaaaaaaa","svg":"","color":""},{"name":"Box","size":8,"bricks":"yyyyyyyyy______yy_bbbb_yy_b__b_yy_b__b_yy_bbbb_yy______yyyyyyyyy","svg":"","color":"","squared":false},{"name":"Rose","size":9,"bricks":"__SS______SSSS_____SSSS_____SSSS______SS_k______k_kk_____kk_k______kk________k","svg":"","color":""},{"name":"Time","size":9,"bricks":"__________WWWWWWW___WWWWW_____yyy_______y________y_______WyW_____WyyyW___yyyyyyy__________","svg":"","color":"","squared":false},{"name":"Watermelon","size":8,"bricks":"_____Sk_____SSBk___SBSSk__SSSSSk_SSBSSk_SBSSSSk_kSSSkk___kkk____","svg":"","color":""},{"name":"Worms","size":13,"bricks":"___sssss_______sssssss______WWsWWsss_____WBsBWsss_____WBsBWsss_____WWsWWsss_____sssssss_______ssssss_____WWWWWWss_______WssWs__s_____ssss__sss___sssssssssss__sssssssss_ss","svg":" ","color":"","squared":false},{"name":"Ocean Sunrise","size":8,"bricks":"SSSSSSSSSSSyySSSSSyyyySSSyyWWyySbttaattbbbttttbbbbbttbbbbbbbbbbb","svg":"","color":""},{"name":"Crosses","size":13,"bricks":"b___b___b___b__v___v___v___vvv_vvv_vvv___v___v___v__p___p___p___ppp_ppp_ppp_ppp___p___p___p__P___P___P___PPP_PPP_PPP___P___P___P__p___p___p___ppp_ppp_ppp_ppp___p___p___p","svg":"","color":""},{"name":"Negative space","size":9,"bricks":"tttttttttt_t_t_t_t_________b_b_b_b_bbbbbbbbbb_b_b_b_b___________t_t_t_t_ttttttttt_________","svg":""},{"name":"UK","size":11,"bricks":"brbbWrWbbrbbbrbWrWbrbbbbbrWrWrbbbWWWWWrWWWWWrrrrrrrrrrrWWWWWrWWWWWbbbrWrWrbbbbbrbWrWbrbbbrbbWrWbbrb__________","svg":"","color":""},{"name":"Greece","size":11,"bricks":"ttWttttttttttWttWWWWWWWWWWWttttttttWttWWWWWWttWttttttttWWWWWWWWWWWtttttttttttWWWWWWWWWWWttttttttttt__________","svg":"","color":""},{"name":"Russia","size":8,"bricks":"________WWWWWWWWWWWWWWWWttttttttttttttttrrrrrrrrrrrrrrrr________________","svg":"","color":""},{"name":"Ukraine","size":8,"bricks":"________ttttttttttttttttttttttttyyyyyyyyyyyyyyyyyyyyyyyy________","svg":"","color":""},{"name":"Poland","size":7,"bricks":"________WWWWW__WWWWW__rrrrr__rrrrr_______________","svg":"","color":""},{"name":"Yellow 71","size":9,"bricks":"_________yyyyy__yyyyyyy_yyy___yy__yy__yyy__yy_yyy___yy_yy____yy_yy____yy__________________","svg":"","color":""},{"name":"71 on white","size":6,"bricks":"WWWWWWrrrWWrWWrWrrWrWWWrWrWWWrWWWWWW______","svg":""},{"name":"Blue 71","size":8,"bricks":"ttttt__bttttt_bb___ttbbb__tt__bb__tt__bb_tt___bb_tt___bb_tt___bb","svg":"","color":""},{"name":"Seventy one","size":21,"bricks":"rr_yy_rrry_yrrry_yrrrr_ry_yr__y_yr_ry_y_r_rr_yy_rr_yy_r_ry_y_r_r_ry_yr__y_yr_ry_y_r_rr_y_yrrry_yrrryyy_r_yyy__________________y______________r_____yyyrrry_yrrryyyrr_y_y__yrr_y_yrr_y_yr__y_yyyyrrr_y_rrry_yrrryyy____________________yrrryyyrrr_________yy_r_ry_yrr_____________rrry_yrrryyyyyyyyyyyy_____________________________________________________________________________________________________________________________","svg":""},{"name":"B71","size":10,"bricks":"__________bbbtttt_b_b__b__tbb_b__b__t_b_bbb__t__b_b__b_t__b_b__bt___b_bbb_t__bbb__________","svg":""},{"name":"Pig","size":9,"bricks":"__________PP___PP__PPP_PPP__WWPPPWW__WBPPPBW__PPsssPP__PsBsBsP__PPsssPP___________","svg":""},{"name":"Big Pig","size":15,"bricks":"________________sss_______sss__ss__sssss__ss____sssssssss_____sWBsssssBWs___ssBBsssssBBss__ssss_____ssss__sss_sssss_sss__sss_sBsBs_sss__sss_sssss_sss___sss_____sss____sssssssssss__GGGsssssssssGGGGGGsGsssssGsGGGGGGssGGGGGssGGG_______________","svg":"","color":""},{"name":"Donkey Kong","size":9,"bricks":"OOr__a___OOr__a___ppppppp_O______a________a____pppppppr_a______b_a___O__ppppppp__","svg":" ","color":""},{"name":"Banana","size":12,"bricks":"_________________e__________eee_________eee_________eee_________eeeyy_____yyeeyyyy___yyyyey_yC___yy_yyy___C_____yyyy_________yyyy_________yyyy","svg":""},{"name":"Fox","size":8,"bricks":"e______eee_OO_eeeeOOOOeeeOBOOBOeOOOOOOOO_WWBBWW___WWWW_____WW___","svg":""},{"name":"Wiki","size":10,"bricks":"_______________________GGGG_____GGkkGG___GkggggkG__GgWWWWgG__GkggggkG___GGkkGG_____GGGG_______________________","svg":""},{"name":"Baby Dog","size":8,"bricks":"_______W__eeeeWWWWeeWeWWWegWegeeeeWWWWee_eWggWe__eWWWWe____WW","svg":""},{"name":"Cute dog","size":9,"bricks":"__________O_____O_OOOWWWOOOOOWWWWWOOOOeWWWWOO_eBeWWBW__eBeWWBW___eWBWW_____WRW____________","svg":""},{"name":"icon:extra_life","size":9,"bricks":"___________rr_rr___rrrrrrr_rrrrrrrrrrrrrrrrrr_rrrrrrr___rrrrr_____rrr_______r_____________","svg":""},{"name":"icon:streak_shots","size":8,"bricks":"_W_W_W__W_W_W_W_tttttt_WttttttW_tttttt_W______W______W_____WWWW","svg":""},{"name":"icon:base_combo","size":8,"bricks":"ttttttttttyyttttttyytyyttttttyyttyyttttttyytyyttttttyytttttttttt________","svg":""},{"name":"icon:slow_down","size":10,"bricks":"_____________kk_______kkkk_____kkkkkkGG__kkkkkkGBG_kkkkkkGGGGkkkkkkGG__GGGGGG____GG__GG_____________","svg":""},{"name":"icon:bigger_puck","size":8,"bricks":"_________tttttt__tttttt______________________W___________WWWWWW_","svg":""},{"name":"icon:viscosity","size":8,"bricks":"________tt______bbtt__ttbbbbttbbbtbbtbbbbbtbbtbbbbbybbybbbbbbbbb","svg":""},{"name":"icon:sides_are_lava","size":8,"bricks":"r______rrttttttrrttttttrr______rr______rr____W_rr______rr_WWW__r","svg":""},{"name":"icon:telekinesis","size":8,"bricks":"_____PW_____s______P______s_______P_______s_______P_____WWWWW","svg":""},{"name":"icon:top_is_lava","size":8,"bricks":"rrrrrrrr_tttttt__tttttt____________________W_______________WWW__","svg":""},{"name":"icon:coin_magnet","size":8,"bricks":"__y__y_yy_________y_y_y_y________y_y______________y______WWW____","svg":""},{"name":"icon:skip_last","size":5,"bricks":"_ttt_t_t_ttt_ttt_t_t_ttt_","svg":""},{"name":"icon:multiball","size":8,"bricks":"_________tttttt__tttttt___________W__W____________________WWW___","svg":""},{"name":"icon:smaller_puck","size":8,"bricks":"_________tttttt__tttttt_____________W_____________________WW____","svg":""},{"name":"icon:pierce","size":6,"bricks":"ttttttttttWtttt__ttt__ttt__ttt__tttt","svg":""},{"name":"icon:picky_eater","size":8,"bricks":"rtrtrtrttrtrtrtrrtrtrtrt____________________t_____________WWWW","svg":""},{"name":"icon:metamorphosis","size":8,"bricks":"aaaaaa__aaaa__________W___________ttaatt__tttttt_________WWW","svg":""},{"name":"icon:compound_interest","size":8,"bricks":"_________tttttt__ttt__t______y_____________W__y_________rrWWWrrr","svg":""},{"name":"icon:hot_start","size":7,"bricks":"ttttttttttt_tt_____W_____y_y_____y_____y_y_WWW_y_","svg":""},{"name":"icon:sapper","size":9,"bricks":"_____WW______W__W_tttWttt_yttgggtt__tgggggt__tgggggt__tgggggt__ttgggtt__ttttttt___________","svg":"","color":"#000000"},{"name":"icon:bigger_explosions","size":8,"bricks":"__r_______ry_rr___ryry__ryyyW_rr_rrWyyy___yryrr__yrry_rr_rr","svg":""},{"name":"icon:extra_levels","size":6,"bricks":"__________b__t_bb_ttt_b__t_bbb____________","svg":""},{"name":"icon:pierce_color","size":8,"bricks":"bb___bbbb__b_bbb_____bbb____bbbbb____bbbbb____bbbbb____bbbbb____","svg":""},{"name":"icon:soft_reset","size":8,"bricks":"___rg_____rrgg___rryggg_rryWggggrryWgggg_ryyggg___rrgg_____rg___","svg":""},{"name":"icon:ball_repulse_ball","size":8,"bricks":"WsP__PsWs______sP______P________________P______Ps______sWsP__PsW","svg":""},{"name":"icon:ball_attract_ball","size":8,"bricks":"__P__P____s__s__PsW__WsP________________PsW__WsP__s__s____P__P__","svg":""},{"name":"icon:puck_repulse_ball","size":8,"bricks":"__________________W_______s___W___P__s______P____________WWW__","svg":""},{"name":"A","size":7,"bricks":"___t_____ttt___t___t__t___t_tttttttt_____tt_____t","svg":""},{"name":"B","size":9,"bricks":"_bbbbb_____bb_bb____bb_bb____bb_bb____bbbb_____bb_bb____bb_bb____bb_bb___bbbbb____","svg":""},{"name":"C","size":8,"bricks":"__rrrr___rrrrrr_rrr___rrrr______rr______rrr___rr_rrrrrr___rrrr","svg":""},{"name":"D","size":8,"bricks":"_GGGGG____GG__G___GG__GG__GG__GG__GG__GG__GG__GG__GG__G__GGGGG","svg":""},{"name":"e","size":8,"bricks":"__tttt___tttttt_tt____tttt____tttttttttttt_______tt__tt___tttt_","svg":""},{"name":"icon:wind","size":9,"bricks":"_ss______s___PPPP_s_________sssssss___________sssssss_s________s___PPPP__ss","svg":""},{"name":"icon:sturdy_bricks","size":7,"bricks":"ttbttttbtttbtt____W_____W_W___W___W_______WWW____","svg":""},{"name":"icon:respawn","size":9,"bricks":"tttt___ttttt__t__ttta_ttt_______________________________W_________________WWW","svg":""},{"name":"Elephant","size":18,"bricks":"_________________________llll_________lll_llllll_lll___lsssllllllllsssl__lsssllllllllsssl__lsssllBllBllsssl__lssllWllllWllssl___ll__llllll__ll_________llll_______________ll______________llll______________ll________________________________________________________________________________________________________________________________________","svg":"","color":""},{"name":"Orca","size":20,"bricks":"____________________________________________________________________________________________BBBBB____BBB_BBB___BBBBBBB____BBBBB___BBBBBBBBB____BBB___BBBBWBBWWW_____BBBBBBBBBBBWWWW_____BBBBBBBBBBWWWWW_____BBBBBBBBBWWWWW_______BBBBBBBWWWWW___________WWBBWWW______________BBB_BB______________BB__B______________________________________________________________________________________________________________________________","svg":"","color":"#1c71d8"},{"name":"Shark","size":17,"bricks":"__________________________________________g_______________ggg____________ggggggg_________ggggggggg_______ggggggggggg_____gggggWWWggggg____gBgWWWWWWWgBg___ggWWWWrWrWWWWgg__ggWWWrrrrrWWWgg_ggWWWrrrrrrrWWWggggWWrrrrrrrrrWWgggWWWrWrWrWrWrWWWggWWrrWWWWWWWrrWWggWWWWWWWWWWWWWWWg_________________","svg":"","color":"#3584e4"},{"name":"Bird","size":13,"bricks":"_______RRR____R____RSSSR___RR__RSSWWWR__RSR_RSWWBWR__RSSRRSW_WWyy_RSSSRSWWWR___RSSSSSSRR_____RRSSyyyy______RSyyyyy___RRRRSyyyy____RSSSRyyy_____RRRR________","svg":"","color":""},{"name":"Tux","size":14,"bricks":"_____gggg________gggggggg_____gggggggggg____gggggggggg___gggggggggggg__gggWBggWBggg__gggBBggBBggg__ggggyyyygggg_ggggggyyggggggggggWWWWWWggggg_gWWWWWWWWg_g__WWWWWWWWWW____WWWWWWWWWW____yyy____yyy__","svg":"","color":"#62a0ea"},{"name":"Armenia","size":6,"bricks":"_______rrrr__bbbb__yyyy_____________","svg":"","color":""},{"name":"Austria","size":6,"bricks":"_______rrrr__WWWW__rrrr______","svg":"","color":""},{"name":"Benin","size":8,"bricks":"_________kkyyyy__kkyyyy__kkrrrr__kkrrrr__________________________","svg":"","color":""},{"name":"Botswana","size":10,"bricks":"___________tttttttt__tttttttt__tttttttt__WWWWWWWW__BBBBBBBB__WWWWWWWW__tttttttt__tttttttt__tttttttt___________","svg":"","color":""},{"name":"Bulgaria","size":6,"bricks":"_______WWWW__cccc__rrrr_____________","svg":"","color":""},{"name":"Canada","size":7,"bricks":"________rWWWr__rWrWr__rWWWr______________________","svg":"","color":""},{"name":"Chad","size":8,"bricks":"_________bbyyRR__bbyyRR__bbyyRR","svg":"","color":""},{"name":"China","size":8,"bricks":"_________RRyRRR__RyRyRR__RRyRRR__RRRRRR","svg":"","color":""},{"name":"Colombia","size":7,"bricks":"________yyyyy__yyyyy__bbbbb__RRRRR_______________","svg":"","color":""},{"name":"Republic of the Congo","size":7,"bricks":"________kkkyy__kkyyr__kyyrr__yyrrr_______________","svg":"","color":""},{"name":"C\xf4te d\'Ivoire","size":8,"bricks":"_________OOWWGG__OOWWGG__OOWWGG","svg":"","color":""},{"name":"Denmark","size":8,"bricks":"_________rrWrrr__rrWrrr__WWWWWW__rrWrrr__rrWrrr","svg":"","color":""},{"name":"El Salvador","size":8,"bricks":"_________bbbbbb__bbbbbb__WWWkWW__WWkWWW__bbbbbb__bbbbbb","svg":"","color":""},{"name":"Egypt","size":8,"bricks":"_________RRRRRR__RRRRRR__WWWyWW__WWyWWW__gggggg__gggggg","svg":"","color":"#1c71d8"},{"name":"Estonia","size":8,"bricks":"_________tttttt__tttttt__gggggg__gggggg__WWWWWW__WWWWWW","svg":"","color":"#986a44"},{"name":"Finland","size":6,"bricks":"_______WtWW__tttt__WtWW_____________","svg":"","color":""},{"name":"Gabon","size":5,"bricks":"______CCC__yyy__ttt______","svg":"","color":""},{"name":"Georgia","size":9,"bricks":"__________WrWrWrW__WWWrWWW__rrrrrrr__WWWrWWW__WrWrWrW__________________","svg":"","color":""},{"name":"Guinea","size":8,"bricks":"_________rryycc__rryycc__rryycc","svg":"","color":""},{"name":"Indonesia","size":6,"bricks":"_______rrrr__rrrr__WWWW__WWWW_______","svg":"","color":""},{"name":"icon:one_more_choice","size":7,"bricks":"ttt____tbbb___tbttt__tbtbbb__btbbb___tbbb____bbb_","svg":""},{"name":"icon:instant_upgrade","size":5,"bricks":"ttt__tbbb_tbbb_tbbb__bbb_","svg":""},{"name":"icon:checkmark_checked","size":6,"bricks":"_WWWWGWBBBGGGGBGGWWGGGBWWBGBBW_WWWW_","svg":""},{"name":"icon:checkmark_unchecked","size":6,"bricks":"_WWWW_WBBBBWWBBBBWWBBBBWWBBBBW_WWWW_","svg":""},{"name":"icon:fullscreen","size":6,"bricks":"WW__WWW____W____________W____WWW__WW","svg":""},{"name":"icon:exit_fullscreen","size":6,"bricks":"_W__W_WW__WW____________WW__WW_W__W_","svg":""}]'); +module.exports = JSON.parse('[{"name":"71 mini","size":5,"bricks":"bbb____bt__btt__b_t___ttt","svg":"","color":""},{"name":"Butterfly","bricks":"_________bb_t_t_bbbbb_t_bbbbbbbtbbbb_bbbtbbb____btb____bbbtbbb__bb_t_bb___________________","size":9,"svg":"","color":""},{"name":"Castle","size":7,"bricks":"s_s_s_ssssssssssBBBssssBBBssttbbbttttbbbtttbtbtbt","svg":""},{"name":"Eyes","size":9,"bricks":"ttttttt__tWWWWWWW_tWrrWttW_tWWWWWWW_ttttttt_____t______ttttt____ttttt_____t_t","svg":"","color":""},{"name":"Creeper","size":10,"bricks":"___________ccGGccGG__cGccGcGc__GBBccBBc__cBBGcBBc__GccBBGGc__ccBBBBcG__GGBBBBcG__cGBccBGc___________","svg":""},{"name":"Stairs","size":8,"bricks":"tt______tt______bbtt____bbtt____vvbbtt__vvbbtt__ppvvbbttppvvbbtt","svg":""},{"name":"Dots","size":9,"bricks":"b_t_a_c_C__________b_t_a_c__________v_b_t_a_c__________v_b_t_a__________p_v_b_t_a","svg":""},{"name":"Lines","size":9,"bricks":"aaaaaaaa___________tttttttt_________aaaaaaaa___________tttttttt_________aaaaaaaa","svg":"","color":""},{"name":"Heart","size":15,"bricks":"__________________RRR___RRR_____RSSSR_RSSSR___RSWWSSRSSSSSR__RSWSSSSSSSSSR__RSSSSSSSSSSSR__RSWSSSSSSSSSR___RSSSSSSSSSR_____RSSSSSSSR_______RSSSSSR_________RSSSR___________RSR_____________R____________________________________","svg":"","color":""},{"name":"Swiss","size":7,"bricks":"________RRRRR__RRWRR__RWWWR__RRWRR__RRRRR","svg":"","color":""},{"name":"Germany","size":6,"bricks":"_______gggg__rrrr__yyyy","svg":"","color":""},{"name":"France","size":8,"bricks":"_________ttWWrr__ttWWrr__ttWWrr__ttWWrr__ttWWrr________","svg":"","color":""},{"name":"Smiley","size":8,"bricks":"_________yy__yy__yy__yy__________________yyyyyy___yyyy__________","svg":"","color":""},{"name":"Labyrinthe","size":11,"bricks":"_______tttS_Stttt_S________t___S__Stt_ttttt____t_____S__ttt_S_S____t___t_tttt_t_S_t____tSt_t_t_Sttt___t_t_____Sttt_tttttS","svg":""},{"name":"Temple","size":11,"bricks":"_______________WWW______WWWWWWW___WWWWWWWWW___t_t_t_t____b_b_b_b____v_v_v_v____p_p_p_p____P_P_P_P____WWWWWWW___WWWWWWWWW_","svg":"","color":""},{"name":"Pacman","size":12,"bricks":"____yyyy______yyyyyyyy___yyyyByyyyy__yyyyyyyyy__yyyyyyyy____yyyyyy______yyyyyy___S_Syyyyyyyy_____yyyyyyyyy___yyyyyyyyyy___yyyyyyyy______yyyy","svg":"","color":""},{"name":"Ship","size":11,"bricks":"____sWW________sWWW_______sWWW_______s___OOOOOOOOOOOOOO_OBOBOBOBOO__OOOOOOOO_bbbbbbbbgbbbbgbbbbggbbbggbbbbbbbb","svg":""},{"name":"We come in peace","size":13,"bricks":"________________a_____a_______a___a_______aaaaaaa_____aaBaaaBaa___aaaaaaaaaaa__aaaaaaaaaaa__a_aaaaaaa_a__a_a_____a_a_____aa_aa_____________________________","svg":"","color":""},{"name":"Space mushroom","size":10,"bricks":"______________WW_______WWWW_____WWWWWW___WWBWWBWW__WWWWWWWW____W__W_____W_WW_W___W_W__W_W","svg":"","color":""},{"name":"Wololo","size":9,"bricks":"____WW_OOW___WW__OWW__W___OWWWbbbW_WWW_WbW_WOW__WWb__OW__bbb__O___W_W__O___W_W__O","svg":"","color":""},{"name":"Small heart","size":15,"bricks":"________________________________RRRR___RRRR___RrWWrR_RWWrrR__RWWrrrRWWrrrR__RrrrrrrrrrrrR__RrrrrrrrrrrrR___RrrrrrrrrrR_____RrrrrrrrR_______RrrrrrR_________RrrrR___________RrR_____________R______________________","svg":"","color":""},{"name":"Eye","size":9,"bricks":"____________ggg_____gWWWg___gWbbbWg_gWWbBbWWg_gWbbbWg___gWWWg_____ggg____________","svg":"","color":"#5da3ea"},{"name":"Enderman","size":10,"bricks":"___________gggggggg__gggggggg__gggggggg__gggggggg__vvvggvvv__gggggggg__gggggggg__gggggggg_____________________","svg":"","color":"#ffffff"},{"name":"Mushroom","size":16,"bricks":"_____________________rrrrWW________WWrrrrWWWW_____WWrrrrrrWWWW____WrrWWWWrrWWW___rrrWWWWWWrrrrr__rrrWWWWWWrrWWr__WrrWWWWWWrWWWW__WWrrWWWWrrWWWW__WWrrrrrrrrrWWr__WrrWWWWWWWWrrr_____WWBWWBWW_______WWWBWWBWWW______WWWWWWWWWW_______WWWWWWWW____________________","svg":"","color":""},{"name":"Tulip","size":11,"bricks":"______________R_R_R______RRRRR______RRRRR______RRRRR_______RRR_________k________k_k_k______k_k_k_______kkk_________k________________","svg":"","color":""},{"name":"Chain","size":7,"bricks":"yyy____yBy____yyyyy____yBy____yyyyy____yBy____yyy","svg":"","color":""},{"name":"Marion","size":9,"bricks":"rr_____rr_rr___rr__rrr_rrr__rrrrrrr__rr_r_rr__rr___rr__rr___rr__rr___rr_rrr___rrr","svg":"","color":""},{"name":"Renan","size":9,"bricks":"yyyyyyy___yyyyyyy__yy___yy__yy___yy__yyyyyy___yy_yy____yy__yy___yy___yy_yyy___yyy","svg":"","color":""},{"name":"Violet Pairs","size":8,"bricks":"b_b_b_b_b_b_b_b__________t_t_t_t_t_t_t_t________b_b_b_b_b_b_b_b","svg":"","color":""},{"name":"Red Cups","size":11,"bricks":"___________rBr_rBr_rBrrrr_rrr_rrr___________r_rBr_rBr_rr_rrr_rrr_r___________rBr_rBr_rBrrrr_rrr_rrr__________","svg":"","color":""},{"name":"Cactus","size":10,"bricks":"____G______rG_Gk______G_Gk______kkkk_r_____kkk_G______GkGk_____rGkk_______Gk________kk________kk_____","svg":"","color":""},{"name":"Sunny Face","size":11,"bricks":"____yyy______yyyyyyy___yyyyyyyyy__yyyyyyyyy_yyyWWyWWyyyyyyyyyyyyyyyyyyyyyyyyy_yyWWWWWyy__yyyWWWyyy___yyyyyyy______yyy","svg":"","color":"#5da3ea"},{"name":"Mountain","size":9,"bricks":"_______________W_______WWW______GGWW__W_GGGGG_kkkGGGGG_kkkkGGGGkkkkkGGGGkkkkkkGGG_________","svg":"","color":""},{"name":"Dollar","size":17,"bricks":"________________________G_G______________G_G____________GGGGGGG_________GGGGGGGGG_______GG__G_G__GG______GG__G_G__GG______GG__G_G___________GGGGGGGG__________GGGGGGGG___________G_G__GG______GG__G_G__GG______GG__G_G__GG_______GGGGGGGGG_________GGGGGGG____________G_G______________G_G________________________","svg":"","color":""},{"name":"Waves","size":8,"bricks":"___bbb____bbb____bbttbbbbbttbbbbttttaatttttaattttaaaaaaa","svg":"","color":""},{"name":"Box","size":8,"bricks":"yyyyyyyyy______yy_bbbb_yy_b__b_yy_b__b_yy_bbbb_yy______yyyyyyyyy","svg":"","color":"","squared":false},{"name":"Rose","size":9,"bricks":"__SS______SSSS_____SSSS_____SSSS______SS_k______k_kk_____kk_k______kk________k","svg":"","color":""},{"name":"Time","size":9,"bricks":"__________WWWWWWW___WWWWW_____yyy_______y________y_______WyW_____WyyyW___yyyyyyy__________","svg":"","color":"","squared":false},{"name":"Watermelon","size":8,"bricks":"_____Sk_____SSBk___SBSSk__SSSSSk_SSBSSk_SBSSSSk_kSSSkk___kkk____","svg":"","color":""},{"name":"Worms","size":13,"bricks":"___sssss_______sssssss______WWsWWsss_____WBsBWsss_____WBsBWsss_____WWsWWsss_____sssssss_______ssssss_____WWWWWWss_______WssWs__s_____ssss__sss___sssssssssss__sssssssss_ss","svg":" ","color":"","squared":false},{"name":"Ocean Sunrise","size":8,"bricks":"SSSSSSSSSSSyySSSSSyyyySSSyyWWyySbttaattbbbttttbbbbbttbbbbbbbbbbb","svg":"","color":""},{"name":"Crosses","size":13,"bricks":"b___b___b___b__v___v___v___vvv_vvv_vvv___v___v___v__p___p___p___ppp_ppp_ppp_ppp___p___p___p__P___P___P___PPP_PPP_PPP___P___P___P__p___p___p___ppp_ppp_ppp_ppp___p___p___p","svg":"","color":""},{"name":"Negative space","size":9,"bricks":"tttttttttt_t_t_t_t_________b_b_b_b_bbbbbbbbbb_b_b_b_b___________t_t_t_t_ttttttttt_________","svg":""},{"name":"UK","size":11,"bricks":"brbbWrWbbrbbbrbWrWbrbbbbbrWrWrbbbWWWWWrWWWWWrrrrrrrrrrrWWWWWrWWWWWbbbrWrWrbbbbbrbWrWbrbbbrbbWrWbbrb__________","svg":"","color":""},{"name":"Greece","size":11,"bricks":"ttWttttttttttWttWWWWWWWWWWWttttttttWttWWWWWWttWttttttttWWWWWWWWWWWtttttttttttWWWWWWWWWWWttttttttttt__________","svg":"","color":""},{"name":"Russia","size":8,"bricks":"________WWWWWWWWWWWWWWWWttttttttttttttttrrrrrrrrrrrrrrrr________________","svg":"","color":""},{"name":"Ukraine","size":8,"bricks":"________ttttttttttttttttttttttttyyyyyyyyyyyyyyyyyyyyyyyy________","svg":"","color":""},{"name":"Poland","size":7,"bricks":"________WWWWW__WWWWW__rrrrr__rrrrr_______________","svg":"","color":""},{"name":"Yellow 71","size":9,"bricks":"_________yyyyy__yyyyyyy_yyy___yy__yy__yyy__yy_yyy___yy_yy____yy_yy____yy__________________","svg":"","color":""},{"name":"71 on white","size":6,"bricks":"WWWWWWrrrWWrWWrWrrWrWWWrWrWWWrWWWWWW______","svg":""},{"name":"Blue 71","size":8,"bricks":"ttttt__bttttt_bb___ttbbb__tt__bb__tt__bb_tt___bb_tt___bb_tt___bb","svg":"","color":""},{"name":"Seventy one","size":21,"bricks":"rr_yy_rrry_yrrry_yrrrr_ry_yr__y_yr_ry_y_r_rr_yy_rr_yy_r_ry_y_r_r_ry_yr__y_yr_ry_y_r_rr_y_yrrry_yrrryyy_r_yyy__________________y______________r_____yyyrrry_yrrryyyrr_y_y__yrr_y_yrr_y_yr__y_yyyyrrr_y_rrry_yrrryyy____________________yrrryyyrrr_________yy_r_ry_yrr_____________rrry_yrrryyyyyyyyyyyy_____________________________________________________________________________________________________________________________","svg":""},{"name":"B71","size":10,"bricks":"__________bbbtttt_b_b__b__tbb_b__b__t_b_bbb__t__b_b__b_t__b_b__bt___b_bbb_t__bbb__________","svg":""},{"name":"Pig","size":9,"bricks":"__________PP___PP__PPP_PPP__WWPPPWW__WBPPPBW__PPsssPP__PsBsBsP__PPsssPP___________","svg":""},{"name":"Big Pig","size":15,"bricks":"________________sss_______sss__ss__sssss__ss____sssssssss_____sWBsssssBWs___ssBBsssssBBss__ssss_____ssss__sss_sssss_sss__sss_sBsBs_sss__sss_sssss_sss___sss_____sss____sssssssssss__GGGsssssssssGGGGGGsGsssssGsGGGGGGssGGGGGssGGG_______________","svg":"","color":""},{"name":"Donkey Kong","size":9,"bricks":"OOr__a___OOr__a___ppppppp_O______a________a____pppppppr_a______b_a___O__ppppppp__","svg":" ","color":""},{"name":"Banana","size":12,"bricks":"_________________e__________eee_________eee_________eee_________eeeyy_____yyeeyyyy___yyyyey_yC___yy_yyy___C_____yyyy_________yyyy_________yyyy","svg":""},{"name":"Fox","size":8,"bricks":"e______eee_OO_eeeeOOOOeeeOBOOBOeOOOOOOOO_WWBBWW___WWWW_____WW___","svg":""},{"name":"Wiki","size":10,"bricks":"_______________________GGGG_____GGkkGG___GkggggkG__GgWWWWgG__GkggggkG___GGkkGG_____GGGG_______________________","svg":""},{"name":"Baby Dog","size":8,"bricks":"_______W__eeeeWWWWeeWeWWWegWegeeeeWWWWee_eWggWe__eWWWWe____WW","svg":""},{"name":"Cute dog","size":9,"bricks":"__________O_____O_OOOWWWOOOOOWWWWWOOOOeWWWWOO_eBeWWBW__eBeWWBW___eWBWW_____WRW____________","svg":""},{"name":"icon:extra_life","size":9,"bricks":"___________rr_rr___rrrrrrr_rrrrrrrrrrrrrrrrrr_rrrrrrr___rrrrr_____rrr_______r_____________","svg":""},{"name":"icon:streak_shots","size":8,"bricks":"_W_W_W__W_W_W_W_tttttt_WttttttW_tttttt_W______W______W_____WWWW","svg":""},{"name":"icon:base_combo","size":8,"bricks":"ttttttttttyyttttttyytyyttttttyyttyyttttttyytyyttttttyytttttttttt________","svg":""},{"name":"icon:slow_down","size":10,"bricks":"_____________kk_______kkkk_____kkkkkkGG__kkkkkkGBG_kkkkkkGGGGkkkkkkGG__GGGGGG____GG__GG_____________","svg":""},{"name":"icon:bigger_puck","size":8,"bricks":"_________tttttt__tttttt______________________W___________WWWWWW_","svg":""},{"name":"icon:viscosity","size":8,"bricks":"________tt______bbtt__ttbbbbttbbbtbbtbbbbbtbbtbbbbbybbybbbbbbbbb","svg":""},{"name":"icon:left_is_lava","size":8,"bricks":"r_______rtttttt_rtttttt_r_______r_______r____W__r_______r_WWW___","svg":""},{"name":"icon:right_is_lava","size":8,"bricks":"_______r_ttttttr_ttttttr_______r_______r_____W_r_______r__WWW__r","svg":""},{"name":"icon:telekinesis","size":8,"bricks":"_____PW_____s______P______s_______P_______s_______P_____WWWWW","svg":""},{"name":"icon:top_is_lava","size":8,"bricks":"rrrrrrrr_tttttt__tttttt____________________W_______________WWW__","svg":""},{"name":"icon:coin_magnet","size":8,"bricks":"__y__y_yy_________y_y_y_y________y_y______________y______WWW____","svg":""},{"name":"icon:skip_last","size":5,"bricks":"_ttt_t_t_ttt_ttt_t_t_ttt_","svg":""},{"name":"icon:multiball","size":8,"bricks":"_________tttttt__tttttt___________W__W____________________WWW___","svg":""},{"name":"icon:smaller_puck","size":8,"bricks":"_________tttttt__tttttt_____________W_____________________WW____","svg":""},{"name":"icon:pierce","size":6,"bricks":"ttttttttttWtttt__ttt__ttt__ttt__tttt","svg":""},{"name":"icon:picky_eater","size":8,"bricks":"rtrtrtrttrtrtrtrrtrtrtrt____________________t_____________WWWW","svg":""},{"name":"icon:metamorphosis","size":8,"bricks":"aaaaaa__aaaa__________W___________ttaatt__tttttt_________WWW","svg":""},{"name":"icon:compound_interest","size":8,"bricks":"_________tttttt__ttt__t______y_____________W__y_________rrWWWrrr","svg":""},{"name":"icon:hot_start","size":7,"bricks":"ttttttttttt_tt_____W_____y_y_____y_____y_y_WWW_y_","svg":""},{"name":"icon:sapper","size":9,"bricks":"_____WW______W__W_tttWttt_yttgggtt__tgggggt__tgggggt__tgggggt__ttgggtt__ttttttt___________","svg":"","color":"#000000"},{"name":"icon:bigger_explosions","size":8,"bricks":"__r_______ry_rr___ryry__ryyyW_rr_rrWyyy___yryrr__yrry_rr_rr","svg":""},{"name":"icon:extra_levels","size":6,"bricks":"__________b__t_bb_ttt_b__t_bbb____________","svg":""},{"name":"icon:pierce_color","size":8,"bricks":"bb___bbbb__b_bbb_____bbb____bbbbb____bbbbb____bbbbb____bbbbb____","svg":""},{"name":"icon:soft_reset","size":8,"bricks":"___rg_____rrgg___rryggg_rryWggggrryWgggg_ryyggg___rrgg_____rg___","svg":""},{"name":"icon:ball_repulse_ball","size":8,"bricks":"WsP__PsWs______sP______P________________P______Ps______sWsP__PsW","svg":""},{"name":"icon:ball_attract_ball","size":8,"bricks":"__P__P____s__s__PsW__WsP________________PsW__WsP__s__s____P__P__","svg":""},{"name":"icon:puck_repulse_ball","size":8,"bricks":"__________________W_______s___W___P__s______P____________WWW__","svg":""},{"name":"A","size":7,"bricks":"___t_____ttt___t___t__t___t_tttttttt_____tt_____t","svg":""},{"name":"B","size":9,"bricks":"_bbbbb_____bb_bb____bb_bb____bb_bb____bbbb_____bb_bb____bb_bb____bb_bb___bbbbb____","svg":""},{"name":"C","size":8,"bricks":"__rrrr___rrrrrr_rrr___rrrr______rr______rrr___rr_rrrrrr___rrrr","svg":""},{"name":"D","size":8,"bricks":"_GGGGG____GG__G___GG__GG__GG__GG__GG__GG__GG__GG__GG__G__GGGGG","svg":""},{"name":"e","size":8,"bricks":"__tttt___tttttt_tt____tttt____tttttttttttt_______tt__tt___tttt_","svg":""},{"name":"icon:wind","size":9,"bricks":"_ss______s___PPPP_s_________sssssss___________sssssss_s________s___PPPP__ss","svg":""},{"name":"icon:sturdy_bricks","size":7,"bricks":"ttbttttbtttbtt____W_____W_W___W___W_______WWW____","svg":""},{"name":"icon:respawn","size":9,"bricks":"tttt___ttttt__t__ttta_ttt_______________________________W_________________WWW","svg":""},{"name":"Elephant","size":18,"bricks":"_________________________llll_________lll_llllll_lll___lsssllllllllsssl__lsssllllllllsssl__lsssllBllBllsssl__lssllWllllWllssl___ll__llllll__ll_________llll_______________ll______________llll______________ll________________________________________________________________________________________________________________________________________","svg":"","color":""},{"name":"Orca","size":20,"bricks":"____________________________________________________________________________________________BBBBB____BBB_BBB___BBBBBBB____BBBBB___BBBBBBBBB____BBB___BBBBWBBWWW_____BBBBBBBBBBBWWWW_____BBBBBBBBBBWWWWW_____BBBBBBBBBWWWWW_______BBBBBBBWWWWW___________WWBBWWW______________BBB_BB______________BB__B______________________________________________________________________________________________________________________________","svg":"","color":"#1c71d8"},{"name":"Shark","size":17,"bricks":"__________________________________________g_______________ggg____________ggggggg_________ggggggggg_______ggggggggggg_____gggggWWWggggg____gBgWWWWWWWgBg___ggWWWWrWrWWWWgg__ggWWWrrrrrWWWgg_ggWWWrrrrrrrWWWggggWWrrrrrrrrrWWgggWWWrWrWrWrWrWWWggWWrrWWWWWWWrrWWggWWWWWWWWWWWWWWWg_________________","svg":"","color":"#3584e4"},{"name":"Bird","size":13,"bricks":"_______RRR____R____RSSSR___RR__RSSWWWR__RSR_RSWWBWR__RSSRRSW_WWyy_RSSSRSWWWR___RSSSSSSRR_____RRSSyyyy______RSyyyyy___RRRRSyyyy____RSSSRyyy_____RRRR________","svg":"","color":""},{"name":"Tux","size":14,"bricks":"_____gggg________gggggggg_____gggggggggg____gggggggggg___gggggggggggg__gggWBggWBggg__gggBBggBBggg__ggggyyyygggg_ggggggyyggggggggggWWWWWWggggg_gWWWWWWWWg_g__WWWWWWWWWW____WWWWWWWWWW____yyy____yyy__","svg":"","color":"#62a0ea"},{"name":"Armenia","size":6,"bricks":"_______rrrr__bbbb__yyyy_____________","svg":"","color":""},{"name":"Austria","size":6,"bricks":"_______rrrr__WWWW__rrrr______","svg":"","color":""},{"name":"Benin","size":8,"bricks":"_________kkyyyy__kkyyyy__kkrrrr__kkrrrr__________________________","svg":"","color":""},{"name":"Botswana","size":10,"bricks":"___________tttttttt__tttttttt__tttttttt__WWWWWWWW__BBBBBBBB__WWWWWWWW__tttttttt__tttttttt__tttttttt___________","svg":"","color":""},{"name":"Bulgaria","size":6,"bricks":"_______WWWW__cccc__rrrr_____________","svg":"","color":""},{"name":"Canada","size":7,"bricks":"________rWWWr__rWrWr__rWWWr______________________","svg":"","color":""},{"name":"Chad","size":8,"bricks":"_________bbyyRR__bbyyRR__bbyyRR","svg":"","color":""},{"name":"China","size":8,"bricks":"_________RRyRRR__RyRyRR__RRyRRR__RRRRRR","svg":"","color":""},{"name":"Colombia","size":7,"bricks":"________yyyyy__yyyyy__bbbbb__RRRRR_______________","svg":"","color":""},{"name":"Republic of the Congo","size":7,"bricks":"________kkkyy__kkyyr__kyyrr__yyrrr_______________","svg":"","color":""},{"name":"C\xf4te d\'Ivoire","size":8,"bricks":"_________OOWWGG__OOWWGG__OOWWGG","svg":"","color":""},{"name":"Denmark","size":8,"bricks":"_________rrWrrr__rrWrrr__WWWWWW__rrWrrr__rrWrrr","svg":"","color":""},{"name":"El Salvador","size":8,"bricks":"_________bbbbbb__bbbbbb__WWWkWW__WWkWWW__bbbbbb__bbbbbb","svg":"","color":""},{"name":"Egypt","size":8,"bricks":"_________RRRRRR__RRRRRR__WWWyWW__WWyWWW__gggggg__gggggg","svg":"","color":"#1c71d8"},{"name":"Estonia","size":8,"bricks":"_________tttttt__tttttt__gggggg__gggggg__WWWWWW__WWWWWW","svg":"","color":"#986a44"},{"name":"Finland","size":6,"bricks":"_______WtWW__tttt__WtWW_____________","svg":"","color":""},{"name":"Gabon","size":5,"bricks":"______CCC__yyy__ttt______","svg":"","color":""},{"name":"Georgia","size":9,"bricks":"__________WrWrWrW__WWWrWWW__rrrrrrr__WWWrWWW__WrWrWrW__________________","svg":"","color":""},{"name":"Guinea","size":8,"bricks":"_________rryycc__rryycc__rryycc","svg":"","color":""},{"name":"Indonesia","size":6,"bricks":"_______rrrr__rrrr__WWWW__WWWW_______","svg":"","color":""},{"name":"icon:one_more_choice","size":7,"bricks":"ttt____tbbb___tbttt__tbtbbb__btbbb___tbbb____bbb_","svg":""},{"name":"icon:instant_upgrade","size":5,"bricks":"ttt__tbbb_tbbb_tbbb__bbb_","svg":""},{"name":"icon:checkmark_checked","size":6,"bricks":"_WWWWGWBBBGGGGBGGWWGGGBWWBGBBW_WWWW_","svg":""},{"name":"icon:checkmark_unchecked","size":6,"bricks":"_WWWW_WBBBBWWBBBBWWBBBBWWBBBBW_WWWW_","svg":""},{"name":"icon:fullscreen","size":6,"bricks":"WW__WWW____W____________W____WWW__WW","svg":""},{"name":"icon:exit_fullscreen","size":6,"bricks":"_W__W_WW__WW____________WW__WW_W__W_","svg":""}]'); },{}],"h1X9A":[function(require,module,exports,__globalThis) { module.exports = JSON.parse("\"29020191\""); @@ -3254,14 +3325,29 @@ const rawUpgrades = [ { requires: "", threshold: 0, - id: "sides_are_lava", + id: "left_is_lava", giftable: true, - name: "Shoot straight", + name: "Avoid left side", max: 1, - help: (lvl)=>`More coins if you don't touch the sides.`, - fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin all the next bricks you break. - However, your combo will reset as soon as your ball hits the left or right side. - As soon as your combo rises, the sides become red to remind you that you should avoid hitting them. The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any + help: (lvl)=>`More coins if you don't touch the left side.`, + fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break. + However, your combo will reset as soon as your ball hits the left side . + As soon as your combo rises, the left side becomes red to remind you that you should avoid hitting them. + The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any + of the reset conditions are met.` + }, + { + requires: "", + threshold: 0, + id: "right_is_lava", + giftable: true, + name: "Avoid right side", + max: 1, + help: (lvl)=>`More coins if you don't touch the right side.`, + fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break. + However, your combo will reset as soon as your ball hits the right side . + As soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them. + The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any of the reset conditions are met.` }, { diff --git a/src/game.ts b/src/game.ts index 5cacd3a..dbdf50d 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,25 +1,37 @@ -import {allLevels, appVersion, icons, upgrades} from "./loadGameData"; -import {Ball, BallLike, Coin, colorString, Flash, FlashTypes, Level, PerkId, RunHistoryItem, RunStats} from "./types"; -import {OptionId, options} from "./options"; +import { allLevels, appVersion, icons, upgrades } from "./loadGameData"; +import { + Ball, + BallLike, + Coin, + colorString, + Flash, + FlashTypes, + Level, + PerkId, + RunHistoryItem, + RunStats, + Upgrade, +} from "./types"; +import { OptionId, options } from "./options"; const MAX_COINS = 400; const MAX_PARTICLES = 600; export const gameCanvas = document.getElementById("game") as HTMLCanvasElement; -let ctx = gameCanvas.getContext("2d", {alpha: false}); +let ctx = gameCanvas.getContext("2d", { alpha: false }); - const puckColor = "#FFF"; +const puckColor = "#FFF"; let ballSize = 20; const coinSize = Math.round(ballSize * 0.8); const puckHeight = ballSize; allLevels.forEach((l, li) => { - l.threshold = - li < 8 - ? 0 - : Math.round( - Math.min(Math.pow(10, 1 + (li + l.size) / 30) * 10, 5000) * li, - ); - l.sortKey = ((Math.random() + 3) / 3.5) * l.bricks.filter((i) => i).length; + l.threshold = + li < 8 + ? 0 + : Math.round( + Math.min(Math.pow(10, 1 + (li + l.size) / 30) * 10, 5000) * li, + ); + l.sortKey = ((Math.random() + 3) / 3.5) * l.bricks.filter((i) => i).length; }); let runLevels: Level[] = []; @@ -28,8 +40,8 @@ let currentLevel = 0; const bombSVG = document.createElement("img"); bombSVG.src = - "data:image/svg+xml;base64," + - btoa(` + "data:image/svg+xml;base64," + + btoa(` `); @@ -42,198 +54,207 @@ let baseSpeed = 12; // applied to x and y let combo = 1; function baseCombo() { - return 1 + perks.base_combo * 3 + perks.smaller_puck * 5; + return 1 + perks.base_combo * 3 + perks.smaller_puck * 5; } function resetCombo(x: number | undefined, y: number | undefined) { - const prev = combo; - combo = baseCombo(); - if (!levelTime) { - combo += perks.hot_start * 15; + const prev = combo; + combo = baseCombo(); + if (!levelTime) { + combo += perks.hot_start * 15; + } + if (prev > combo && perks.soft_reset) { + combo += Math.floor((prev - combo) / (1 + perks.soft_reset)); + } + const lost = Math.max(0, prev - combo); + if (lost) { + for (let i = 0; i < lost && i < 8; i++) { + setTimeout(() => sounds.comboDecrease(), i * 100); } - if (prev > combo && perks.soft_reset) { - combo += Math.floor((prev - combo) / (1 + perks.soft_reset)); + if (typeof x !== "undefined" && typeof y !== "undefined") { + flashes.push({ + type: "text", + text: "-" + lost, + time: levelTime, + color: "red", + x: x, + y: y, + duration: 150, + size: puckHeight, + }); } - const lost = Math.max(0, prev - combo); - if (lost) { - for (let i = 0; i < lost && i < 8; i++) { - setTimeout(() => sounds.comboDecrease(), i * 100); - } - if (typeof x !== "undefined" && typeof y !== "undefined") { - flashes.push({ - type: "text", - text: "-" + lost, - time: levelTime, - color: "red", - x: x, - y: y, - duration: 150, - size: puckHeight, - }); - } - } - return lost; + } + return lost; } function decreaseCombo(by: number, x: number, y: number) { - const prev = combo; - combo = Math.max(baseCombo(), combo - by); - const lost = Math.max(0, prev - combo); + const prev = combo; + combo = Math.max(baseCombo(), combo - by); + const lost = Math.max(0, prev - combo); - if (lost) { - sounds.comboDecrease(); - if (typeof x !== "undefined" && typeof y !== "undefined") { - flashes.push({ - type: "text", - text: "-" + lost, - time: levelTime, - color: "red", - x: x, - y: y, - duration: 300, - size: puckHeight, - }); - } + if (lost) { + sounds.comboDecrease(); + if (typeof x !== "undefined" && typeof y !== "undefined") { + flashes.push({ + type: "text", + text: "-" + lost, + time: levelTime, + color: "red", + x: x, + y: y, + duration: 300, + size: puckHeight, + }); } + } } let gridSize = 12; let running = false, - puck = 400, - pauseTimeout: number | null = null; + puck = 400, + pauseTimeout: number | null = null; function play() { - if (running) return; - running = true; - if (audioContext) { - audioContext.resume().then(); - } - resumeRecording(); - document.body.className = running ? " running " : " paused "; + if (running) return; + running = true; + if (audioContext) { + audioContext.resume().then(); + } + resumeRecording(); + document.body.className = running ? " running " : " paused "; } function pause(playerAskedForPause: boolean) { - if (!running) return; - if (pauseTimeout) return; + if (!running) return; + if (pauseTimeout) return; - pauseTimeout = setTimeout( - () => { - running = false; - needsRender = true; - if (audioContext) { - setTimeout(() => { - if (!running) audioContext.suspend().then(); - }, 1000); - } - pauseRecording(); - pauseTimeout = null; - document.body.className = running ? " running " : " paused "; - }, - Math.min(Math.max(0, pauseUsesDuringRun - 5) * 50, 500), - ); + pauseTimeout = setTimeout( + () => { + running = false; + needsRender = true; + if (audioContext) { + setTimeout(() => { + if (!running) audioContext.suspend().then(); + }, 1000); + } + pauseRecording(); + pauseTimeout = null; + document.body.className = running ? " running " : " paused "; + }, + Math.min(Math.max(0, pauseUsesDuringRun - 5) * 50, 500), + ); - if (playerAskedForPause) { - // Pausing many times in a run will make pause slower - pauseUsesDuringRun++; - } + if (playerAskedForPause) { + // Pausing many times in a run will make pause slower + pauseUsesDuringRun++; + } - if (document.exitPointerLock) { - document.exitPointerLock(); - } + if (document.exitPointerLock) { + document.exitPointerLock(); + } } let offsetX: number, - offsetXRoundedDown: number, - gameZoneWidth: number, - gameZoneWidthRoundedUp: number, - gameZoneHeight: number, - brickWidth: number, - needsRender = true; + offsetXRoundedDown: number, + gameZoneWidth: number, + gameZoneWidthRoundedUp: number, + gameZoneHeight: number, + brickWidth: number, + needsRender = true; const background = document.createElement("img"); const backgroundCanvas = document.createElement("canvas"); background.addEventListener("load", () => { - needsRender = true; + needsRender = true; }); export const fitSize = () => { - const {width, height} = gameCanvas.getBoundingClientRect(); - gameCanvas.width = width; - gameCanvas.height = height; - ctx.fillStyle = currentLevelInfo()?.color || "black"; - ctx.globalAlpha = 1; - ctx.fillRect(0, 0, width, height); - backgroundCanvas.width = width; - backgroundCanvas.height = height; + const { width, height } = gameCanvas.getBoundingClientRect(); + gameCanvas.width = width; + gameCanvas.height = height; + ctx.fillStyle = currentLevelInfo()?.color || "black"; + ctx.globalAlpha = 1; + ctx.fillRect(0, 0, width, height); + backgroundCanvas.width = width; + backgroundCanvas.height = height; - gameZoneHeight = isSettingOn("mobile-mode") ? (height * 80) / 100 : height; - const baseWidth = Math.round(Math.min(gameCanvas.width, gameZoneHeight * 0.73)); - brickWidth = Math.floor(baseWidth / gridSize / 2) * 2; - gameZoneWidth = brickWidth * gridSize; - offsetX = Math.floor((gameCanvas.width - gameZoneWidth) / 2); - offsetXRoundedDown = offsetX; - if (offsetX < ballSize) offsetXRoundedDown = 0; - gameZoneWidthRoundedUp = width - 2 * offsetXRoundedDown; - backgroundCanvas.title = "resized"; - // Ensure puck stays within bounds - setMousePos(puck); - coins = []; - flashes = []; - pause(true); - putBallsAtPuck(); - // For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ - document.documentElement.style.setProperty( - "--vh", - `${window.innerHeight * 0.01}px`, - ); + gameZoneHeight = isSettingOn("mobile-mode") ? (height * 80) / 100 : height; + const baseWidth = Math.round( + Math.min(gameCanvas.width, gameZoneHeight * 0.73), + ); + brickWidth = Math.floor(baseWidth / gridSize / 2) * 2; + gameZoneWidth = brickWidth * gridSize; + offsetX = Math.floor((gameCanvas.width - gameZoneWidth) / 2); + offsetXRoundedDown = offsetX; + if (offsetX < ballSize) offsetXRoundedDown = 0; + gameZoneWidthRoundedUp = width - 2 * offsetXRoundedDown; + backgroundCanvas.title = "resized"; + // Ensure puck stays within bounds + setMousePos(puck); + coins = []; + flashes = []; + pause(true); + putBallsAtPuck(); + // For safari mobile https://css-tricks.com/the-trick-to-viewport-units-on-mobile/ + document.documentElement.style.setProperty( + "--vh", + `${window.innerHeight * 0.01}px`, + ); }; window.addEventListener("resize", fitSize); window.addEventListener("fullscreenchange", fitSize); function recomputeTargetBaseSpeed() { - // We never want the ball to completely stop, it will move at least 3px per frame - baseSpeed = Math.max( - 3, - gameZoneWidth / 12 / 10 + - currentLevel / 3 + - levelTime / (30 * 1000) - - perks.slow_down * 2, - ); + // We never want the ball to completely stop, it will move at least 3px per frame + baseSpeed = Math.max( + 3, + gameZoneWidth / 12 / 10 + + currentLevel / 3 + + levelTime / (30 * 1000) - + perks.slow_down * 2, + ); } function brickCenterX(index: number) { - return offsetX + ((index % gridSize) + 0.5) * brickWidth; + return offsetX + ((index % gridSize) + 0.5) * brickWidth; } function brickCenterY(index: number) { - return (Math.floor(index / gridSize) + 0.5) * brickWidth; + return (Math.floor(index / gridSize) + 0.5) * brickWidth; } function getRowColIndex(row: number, col: number) { - if (row < 0 || col < 0 || row >= gridSize || col >= gridSize) return -1; - return row * gridSize + col; + if (row < 0 || col < 0 || row >= gridSize || col >= gridSize) return -1; + return row * gridSize + col; } -function spawnExplosion(count: number, x: number, y: number, color: string, duration = 150, size = coinSize) { - if (!!isSettingOn("basic")) return; - if (flashes.length > MAX_PARTICLES) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - flashes.push({ - type: "particle", - time: levelTime, - size, - x: x + ((Math.random() - 0.5) * brickWidth) / 2, - y: y + ((Math.random() - 0.5) * brickWidth) / 2, - vx: (Math.random() - 0.5) * 30, - vy: (Math.random() - 0.5) * 30, - color, - duration, - }); - } +function spawnExplosion( + count: number, + x: number, + y: number, + color: string, + duration = 150, + size = coinSize, +) { + if (!!isSettingOn("basic")) return; + if (flashes.length > MAX_PARTICLES) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + flashes.push({ + type: "particle", + time: levelTime, + size, + x: x + ((Math.random() - 0.5) * brickWidth) / 2, + y: y + ((Math.random() - 0.5) * brickWidth) / 2, + vx: (Math.random() - 0.5) * 30, + vy: (Math.random() - 0.5) * 30, + color, + duration, + }); + } } let score = 0; @@ -244,985 +265,1017 @@ let highScore = parseFloat(localStorage.getItem("breakout-3-hs") || "0"); let lastPlayedCoinGrab = 0; function addToScore(coin: Coin) { - coin.destroyed = true; - score += coin.points; - addToTotalScore(coin.points); - if (score > highScore) { - highScore = score; - localStorage.setItem("breakout-3-hs", score.toString()); - } - if (!isSettingOn("basic")) { - flashes.push({ - type: "particle", - duration: 100 + Math.random() * 50, - time: levelTime, - size: coinSize / 2, - color: coin.color, - x: coin.previousx, - y: coin.previousy, - vx: (gameCanvas.width - coin.x) / 100, - vy: -coin.y / 100, - ethereal: true, - }); - } + coin.destroyed = true; + score += coin.points; - if (Date.now() - lastPlayedCoinGrab > 16) { - lastPlayedCoinGrab = Date.now(); - sounds.coinCatch(coin.x); - } - runStatistics.score += coin.points; + addToTotalScore(coin.points); + if (score > highScore && !ignoreThisRunInStats) { + highScore = score; + localStorage.setItem("breakout-3-hs", score.toString()); + } + if (!isSettingOn("basic")) { + flashes.push({ + type: "particle", + duration: 100 + Math.random() * 50, + time: levelTime, + size: coinSize / 2, + color: coin.color, + x: coin.previousx, + y: coin.previousy, + vx: (gameCanvas.width - coin.x) / 100, + vy: -coin.y / 100, + ethereal: true, + }); + } + + if (Date.now() - lastPlayedCoinGrab > 16) { + lastPlayedCoinGrab = Date.now(); + sounds.coinCatch(coin.x); + } + runStatistics.score += coin.points; } let balls: Ball[] = []; -let ballsColor:colorString = "white" ; +let ballsColor: colorString = "white"; function resetBalls() { - const count = 1 + (perks?.multiball || 0); - const perBall = puckWidth / (count + 1); - balls = []; - ballsColor = "#FFF"; - for (let i = 0; i < count; i++) { - const x = puck - puckWidth / 2 + perBall * (i + 1); - balls.push({ - x, - previousx: x, - y: gameZoneHeight - 1.5 * ballSize, - previousy: gameZoneHeight - 1.5 * ballSize, - vx: Math.random() > 0.5 ? baseSpeed : -baseSpeed, - vy: -baseSpeed, - sx: 0, - sy: 0, - sparks: 0, - piercedSinceBounce: 0, - hitSinceBounce: 0, - hitItem: [], - sapperUses: 0, - }); - } + const count = 1 + (perks?.multiball || 0); + const perBall = puckWidth / (count + 1); + balls = []; + ballsColor = "#FFF"; + if(perks.picky_eater || perks.pierce_color){ + ballsColor=getMajorityValue(bricks.filter(i=>i)) || '#FFF' + } + for (let i = 0; i < count; i++) { + const x = puck - puckWidth / 2 + perBall * (i + 1); + balls.push({ + x, + previousx: x, + y: gameZoneHeight - 1.5 * ballSize, + previousy: gameZoneHeight - 1.5 * ballSize, + vx: Math.random() > 0.5 ? baseSpeed : -baseSpeed, + vy: -baseSpeed, + sx: 0, + sy: 0, + sparks: 0, + piercedSinceBounce: 0, + hitSinceBounce: 0, + hitItem: [], + sapperUses: 0, + }); + } } function putBallsAtPuck() { - // This reset could be abused to cheat quite easily - const count = balls.length; - const perBall = puckWidth / (count + 1); - balls.forEach((ball, i) => { - const x = puck - puckWidth / 2 + perBall * (i + 1); - ball.x = x; - ball.previousx = x; - ball.y = gameZoneHeight - 1.5 * ballSize; - ball.previousy = ball.y; - ball.vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; - ball.vy = -baseSpeed; - ball.sx = 0; - ball.sy = 0; - ball.hitItem = []; - ball.hitSinceBounce = 0; - ball.piercedSinceBounce = 0; - }); + // This reset could be abused to cheat quite easily + const count = balls.length; + const perBall = puckWidth / (count + 1); + balls.forEach((ball, i) => { + const x = puck - puckWidth / 2 + perBall * (i + 1); + ball.x = x; + ball.previousx = x; + ball.y = gameZoneHeight - 1.5 * ballSize; + ball.previousy = ball.y; + ball.vx = Math.random() > 0.5 ? baseSpeed : -baseSpeed; + ball.vy = -baseSpeed; + ball.sx = 0; + ball.sy = 0; + ball.hitItem = []; + ball.hitSinceBounce = 0; + ball.piercedSinceBounce = 0; + }); } resetBalls(); // Default, recomputed at each level load -let bricks : colorString[] = []; -let flashes :Flash[] = []; +let bricks: colorString[] = []; +let flashes: Flash[] = []; let coins: Coin[] = []; let levelStartScore = 0; let levelMisses = 0; let levelSpawnedCoins = 0; function pickedUpgradesHTMl() { - let list = ""; - for (let u of upgrades) { - for (let i = 0; i < perks[u.id]; i++) list += icons["icon:" + u.id] + " "; - } - return list; + let list = ""; + for (let u of upgrades) { + for (let i = 0; i < perks[u.id]; i++) list += icons["icon:" + u.id] + " "; + } + return list; } async function openUpgradesPicker() { - const catchRate = (score - levelStartScore) / (levelSpawnedCoins || 1); + const catchRate = (score - levelStartScore) / (levelSpawnedCoins || 1); - let repeats = 1; - let choices = 3; + let repeats = 1; + let choices = 3; - let timeGain = "", - catchGain = "", - missesGain = ""; - if (levelTime < 30 * 1000) { - repeats++; - choices++; - timeGain = " (+1 upgrade and choice)"; - } else if (levelTime < 60 * 1000) { - choices++; - timeGain = " (+1 choice)"; - } - if (catchRate === 1) { - repeats++; - choices++; - catchGain = " (+1 upgrade and choice)"; - } else if (catchRate > 0.9) { - choices++; - catchGain = " (+1 choice)"; - } - if (levelMisses === 0) { - repeats++; - choices++; - missesGain = " (+1 upgrade and choice)"; - } else if (levelMisses <= 3) { - choices++; - missesGain = " (+1 choice)"; - } + let timeGain = "", + catchGain = "", + missesGain = ""; + if (levelTime < 30 * 1000) { + repeats++; + choices++; + timeGain = " (+1 upgrade and choice)"; + } else if (levelTime < 60 * 1000) { + choices++; + timeGain = " (+1 choice)"; + } + if (catchRate === 1) { + repeats++; + choices++; + catchGain = " (+1 upgrade and choice)"; + } else if (catchRate > 0.9) { + choices++; + catchGain = " (+1 choice)"; + } + if (levelMisses === 0) { + repeats++; + choices++; + missesGain = " (+1 upgrade and choice)"; + } else if (levelMisses <= 3) { + choices++; + missesGain = " (+1 choice)"; + } - while (repeats--) { - const actions = pickRandomUpgrades( - choices + perks.one_more_choice - perks.instant_upgrade, - ); - if (!actions.length) break; - let textAfterButtons = ` + while (repeats--) { + const actions = pickRandomUpgrades( + choices + perks.one_more_choice - perks.instant_upgrade, + ); + if (!actions.length) break; + let textAfterButtons = `

You just finished level ${currentLevel + 1}/${max_levels()} and picked those upgrades so far :

${pickedUpgradesHTMl()}

`; - const upgradeId = (await asyncAlert({ - title: "Pick an upgrade " + (repeats ? "(" + (repeats + 1) + ")" : ""), - actions, - text: `

+ const upgradeId = (await asyncAlert({ + title: "Pick an upgrade " + (repeats ? "(" + (repeats + 1) + ")" : ""), + actions, + text: `

You caught ${score - levelStartScore} coins ${catchGain} out of ${levelSpawnedCoins} in ${Math.round(levelTime / 1000)} seconds${timeGain}. You missed ${levelMisses} times ${missesGain}. ${(timeGain && catchGain && missesGain && "Impressive, keep it up !") || ((timeGain || catchGain || missesGain) && "Well done !") || "Try to catch all coins, never miss the bricks or clear the level under 30s to gain additional choices and upgrades."}

`, - allowClose: false, - textAfterButtons, - })) as PerkId; + allowClose: false, + textAfterButtons, + })) as PerkId; - perks[upgradeId]++; - if (upgradeId === "instant_upgrade") { - repeats += 2; - } - - runStatistics.upgrades_picked++; + perks[upgradeId]++; + if (upgradeId === "instant_upgrade") { + repeats += 2; } - resetCombo(undefined, undefined); - resetBalls(); + + runStatistics.upgrades_picked++; + } + resetCombo(undefined, undefined); + resetBalls(); } -function setLevel(l:number) { - pause(false); - if (l > 0) { - openUpgradesPicker().then(); - } - currentLevel = l; +function setLevel(l: number) { + pause(false); + if (l > 0) { + openUpgradesPicker().then(); + } + currentLevel = l; - levelTime = 0; - lastTickDown = levelTime; - levelStartScore = score; - levelSpawnedCoins = 0; - levelMisses = 0; - runStatistics.levelsPlayed++; + levelTime = 0; + level_skip_last_uses = 0; + lastTickDown = levelTime; + levelStartScore = score; + levelSpawnedCoins = 0; + levelMisses = 0; + runStatistics.levelsPlayed++; - resetCombo(undefined, undefined); - recomputeTargetBaseSpeed(); - resetBalls(); + resetCombo(undefined, undefined); + recomputeTargetBaseSpeed(); + resetBalls(); - const lvl = currentLevelInfo(); - if (lvl.size !== gridSize) { - gridSize = lvl.size; - fitSize(); - } - coins = []; - bricks = [...lvl.bricks]; - flashes = []; + const lvl = currentLevelInfo(); + if (lvl.size !== gridSize) { + gridSize = lvl.size; + fitSize(); + } + coins = []; + bricks = [...lvl.bricks]; + flashes = []; - // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons - // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) - background.src = "data:image/svg+xml;UTF8," + lvl.svg; - stopRecording(); - startRecordingGame(); + // 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; + stopRecording(); + startRecordingGame(); } function currentLevelInfo() { - return runLevels[currentLevel % runLevels.length]; + return runLevels[currentLevel % runLevels.length]; } -function reset_perks():PerkId { - for (let u of upgrades) { - perks[u.id] = 0; - } +function reset_perks(): PerkId { + for (let u of upgrades) { + perks[u.id] = 0; + } - const giftable = getPossibleUpgrades().filter((u) => u.giftable); - const randomGift = - nextRunOverrides?.perk || - (isSettingOn("easy") && "slow_down" ) || giftable[Math.floor(Math.random() * giftable.length)].id; + const giftable = getPossibleUpgrades().filter((u) => u.giftable); + const randomGift = + nextRunOverrides?.perk || + (isSettingOn("easy") && "slow_down") || + giftable[Math.floor(Math.random() * giftable.length)].id; - perks[randomGift] = 1; + perks[randomGift] = 1; - delete nextRunOverrides.perk; - return randomGift as PerkId; + delete nextRunOverrides.perk; + return randomGift as PerkId; } let totalScoreAtRunStart = getTotalScore(); function getPossibleUpgrades() { - return upgrades - .filter((u) => totalScoreAtRunStart >= u.threshold) - .filter((u) => !u?.requires || perks[u?.requires]); + return upgrades + .filter((u) => totalScoreAtRunStart >= u.threshold) + .filter((u) => !u?.requires || perks[u?.requires]); } function shuffleLevels(nameToAvoid = null) { - const target = nextRunOverrides?.level; - const firstLevel = nextRunOverrides?.level - ? allLevels.filter((l) => l.name === target) - : []; + const target = nextRunOverrides?.level; + const firstLevel = nextRunOverrides?.level + ? allLevels.filter((l) => l.name === target) + : []; - const restInRandomOrder = allLevels - .filter((l) => totalScoreAtRunStart >= l.threshold) - .filter((l) => l.name !== nextRunOverrides?.level) - .filter((l) => l.name !== nameToAvoid || allLevels.length === 1) - .sort(() => Math.random() - 0.5); + const restInRandomOrder = allLevels + .filter((l) => totalScoreAtRunStart >= l.threshold) + .filter((l) => l.name !== nextRunOverrides?.level) + .filter((l) => l.name !== nameToAvoid || allLevels.length === 1) + .sort(() => Math.random() - 0.5); - runLevels = firstLevel.concat( - restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), - ); + runLevels = firstLevel.concat( + restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey), + ); } function getUpgraderUnlockPoints() { - let list = []; + let list = []; - upgrades.forEach((u) => { - if (u.threshold) { - list.push({ - threshold: u.threshold, - title: u.name + " (Perk)", - }); - } + upgrades.forEach((u) => { + if (u.threshold) { + list.push({ + threshold: u.threshold, + title: u.name + " (Perk)", + }); + } + }); + + allLevels.forEach((l) => { + list.push({ + threshold: l.threshold, + title: l.name + " (Level)", }); + }); - allLevels.forEach((l) => { - list.push({ - threshold: l.threshold, - title: l.name + " (Level)", - }); - }); - - return list - .filter((o) => o.threshold) - .sort((a, b) => a.threshold - b.threshold); + return list + .filter((o) => o.threshold) + .sort((a, b) => a.threshold - b.threshold); } -let lastOffered = {} as {[k in PerkId]:number}; +let lastOffered = {} as { [k in PerkId]: number }; -function dontOfferTooSoon(id:PerkId) { - lastOffered[id] = Math.round(Date.now() / 1000); +function dontOfferTooSoon(id: PerkId) { + lastOffered[id] = Math.round(Date.now() / 1000); } function pickRandomUpgrades(count: number) { - let list = getPossibleUpgrades() - .map((u) => ({...u, score: Math.random() + (lastOffered[u.id] || 0)})) - .sort((a, b) => a.score - b.score) - .filter((u) => perks[u.id] < u.max) - .slice(0, count) - .sort((a, b) => (a.id > b.id ? 1 : -1)); + let list = getPossibleUpgrades() + .map((u) => ({ ...u, score: Math.random() + (lastOffered[u.id] || 0) })) + .sort((a, b) => a.score - b.score) + .filter((u) => perks[u.id] < u.max) + .slice(0, count) + .sort((a, b) => (a.id > b.id ? 1 : -1)); - list.forEach((u) => { - dontOfferTooSoon(u.id); - }); + list.forEach((u) => { + dontOfferTooSoon(u.id); + }); - return list.map((u) => ({ - text: u.name + (perks[u.id] ? " lvl " + (perks[u.id] + 1) : ""), - icon: icons["icon:" + u.id], - value: u.id as PerkId, - help: u.help(perks[u.id] + 1), - })); + return list.map((u) => ({ + text: u.name + (perks[u.id] ? " lvl " + (perks[u.id] + 1) : ""), + icon: icons["icon:" + u.id], + value: u.id as PerkId, + help: u.help(perks[u.id] + 1), + })); } type RunOverrides = { level?: PerkId; perk?: string }; let nextRunOverrides = {} as RunOverrides; - +let ignoreThisRunInStats = false; let pauseUsesDuringRun = 0; function restart() { - // When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next - // run's level list - totalScoreAtRunStart = getTotalScore(); - shuffleLevels(levelTime || score ? currentLevelInfo().name : null); - resetRunStatistics(); - score = 0; - pauseUsesDuringRun = 0; + // When restarting, we want to avoid restarting with the same level we're on, so we exclude from the next + // run's level list + totalScoreAtRunStart = getTotalScore(); + ignoreThisRunInStats = false; + shuffleLevels(levelTime || score ? currentLevelInfo().name : null); + resetRunStatistics(); + score = 0; + pauseUsesDuringRun = 0; - const randomGift = reset_perks(); + const randomGift = reset_perks(); - dontOfferTooSoon(randomGift); + dontOfferTooSoon(randomGift); - setLevel(0); - pauseRecording(); + setLevel(0); + pauseRecording(); } let keyboardPuckSpeed = 0; -function setMousePos(x:number) { - needsRender = true; - puck = x; +function setMousePos(x: number) { + needsRender = true; + puck = x; - // We have borders visible, enforce them - if (puck < offsetXRoundedDown + puckWidth / 2) { - puck = offsetXRoundedDown + puckWidth / 2; - } - if (puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2) { - puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2; - } - if (!running && !levelTime) { - putBallsAtPuck(); - } + // We have borders visible, enforce them + if (puck < offsetXRoundedDown + puckWidth / 2) { + puck = offsetXRoundedDown + puckWidth / 2; + } + if (puck > offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2) { + puck = offsetXRoundedDown + gameZoneWidthRoundedUp - puckWidth / 2; + } + if (!running && !levelTime) { + putBallsAtPuck(); + } } gameCanvas.addEventListener("mouseup", (e) => { - if (e.button !== 0) return; - if (running) { - pause(true); - } else { - play(); - if (isSettingOn("pointerLock")) { - gameCanvas.requestPointerLock(); - } + if (e.button !== 0) return; + if (running) { + pause(true); + } else { + play(); + if (isSettingOn("pointerLock")) { + gameCanvas.requestPointerLock(); } + } }); gameCanvas.addEventListener("mousemove", (e) => { - if (document.pointerLockElement === gameCanvas) { - setMousePos(puck + e.movementX); - } else { - setMousePos(e.x); - } + if (document.pointerLockElement === gameCanvas) { + setMousePos(puck + e.movementX); + } else { + setMousePos(e.x); + } }); gameCanvas.addEventListener("touchstart", (e) => { - e.preventDefault(); - if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); - play(); + e.preventDefault(); + if (!e.touches?.length) return; + setMousePos(e.touches[0].pageX); + play(); }); gameCanvas.addEventListener("touchend", (e) => { - e.preventDefault(); - pause(true); + e.preventDefault(); + pause(true); }); gameCanvas.addEventListener("touchcancel", (e) => { - e.preventDefault(); - pause(true); - needsRender = true; + e.preventDefault(); + pause(true); + needsRender = true; }); gameCanvas.addEventListener("touchmove", (e) => { - if (!e.touches?.length) return; - setMousePos(e.touches[0].pageX); + if (!e.touches?.length) return; + setMousePos(e.touches[0].pageX); }); let lastTick = performance.now(); -function brickIndex(x:number, y:number) { - return getRowColIndex( - Math.floor(y / brickWidth), - Math.floor((x - offsetX) / brickWidth), - ); +function brickIndex(x: number, y: number) { + return getRowColIndex( + Math.floor(y / brickWidth), + Math.floor((x - offsetX) / brickWidth), + ); } -function hasBrick(index:number):number|undefined { - if (bricks[index]) return index; +function hasBrick(index: number): number | undefined { + if (bricks[index]) return index; } -function hitsSomething(x:number, y:number, radius:number) { - return ( - hasBrick(brickIndex(x - radius, y - radius)) ?? - hasBrick(brickIndex(x + radius, y - radius)) ?? - hasBrick(brickIndex(x + radius, y + radius)) ?? - hasBrick(brickIndex(x - radius, y + radius)) - ); +function hitsSomething(x: number, y: number, radius: number) { + return ( + hasBrick(brickIndex(x - radius, y - radius)) ?? + hasBrick(brickIndex(x + radius, y - radius)) ?? + hasBrick(brickIndex(x + radius, y + radius)) ?? + hasBrick(brickIndex(x - radius, y + radius)) + ); } -function shouldPierceByColor(vhit:number|undefined, hhit:number|undefined, chit:number|undefined) { - if (!perks.pierce_color) return false; - if (typeof vhit !== "undefined" && bricks[vhit] !== ballsColor) { - return false; - } - if (typeof hhit !== "undefined" && bricks[hhit] !== ballsColor) { - return false; - } - if (typeof chit !== "undefined" && bricks[chit] !== ballsColor) { - return false; - } - return true; +function shouldPierceByColor( + vhit: number | undefined, + hhit: number | undefined, + chit: number | undefined, +) { + if (!perks.pierce_color) return false; + if (typeof vhit !== "undefined" && bricks[vhit] !== ballsColor) { + return false; + } + if (typeof hhit !== "undefined" && bricks[hhit] !== ballsColor) { + return false; + } + if (typeof chit !== "undefined" && bricks[chit] !== ballsColor) { + return false; + } + return true; } -function ballBrickHitCheck(ball:Ball) { - const radius=ballSize / 2 - // Make ball/coin bonce, and return bricks that were hit - const {x, y, previousx, previousy} = ball; +function ballBrickHitCheck(ball: Ball) { + const radius = 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 vhit = hitsSomething(previousx, y, radius); + const hhit = hitsSomething(x, previousy, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; - let pierce = ball.piercedSinceBounce < perks.pierce * 3; - if ( - pierce && - (typeof vhit !== "undefined" || - typeof hhit !== "undefined" || - typeof chit !== "undefined") - ) { - ball.piercedSinceBounce++; + let pierce = ball.piercedSinceBounce < perks.pierce * 3; + if ( + pierce && + (typeof vhit !== "undefined" || + typeof hhit !== "undefined" || + typeof chit !== "undefined") + ) { + ball.piercedSinceBounce++; + } + if (shouldPierceByColor(vhit, hhit, chit)) { + pierce = true; + } + + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.y = ball.previousy; + ball.vy *= -1; } - if ( shouldPierceByColor(vhit, hhit, chit)) { - pierce = true; + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.x = ball.previousx; + ball.vx *= -1; } + } - 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; - } - } - - return vhit ?? hhit ?? chit; + return vhit ?? hhit ?? chit; } -function coinBrickHitCheck(coin:Coin) { +function coinBrickHitCheck(coin: Coin) { + // Make ball/coin bonce, and return bricks that were hit + const radius = coinSize / 2; + const { x, y, previousx, previousy } = coin; - // Make ball/coin bonce, and return bricks that were hit - const radius=coinSize/2 - const {x, y, previousx, previousy} = coin; + const vhit = hitsSomething(previousx, y, radius); + const hhit = hitsSomething(x, previousy, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; - const vhit = hitsSomething(previousx, y, radius); - const hhit = hitsSomething(x, previousy, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + coin.y = coin.previousy; + coin.vy *= -1; - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - coin.y = coin.previousy; - coin.vy *= -1; + // Roll on corners + const leftHit = bricks[brickIndex(x - radius, y + radius)]; + const rightHit = bricks[brickIndex(x + radius, y + radius)]; - // Roll on corners - const leftHit = bricks[brickIndex(x - radius, y + radius)]; - const rightHit = 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 (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; } - return vhit ?? hhit ?? chit; + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + coin.x = coin.previousx; + coin.vx *= -1; + } + return vhit ?? hhit ?? chit; } -function bordersHitCheck(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; +function bordersHitCheck(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 (perks.wind) { - coin.vx += - ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * - perks.wind * - 0.5; - } + if (perks.wind) { + coin.vx += + ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * + perks.wind * + 0.5; + } - let vhit = 0, - hhit = 0; + let vhit = 0, + hhit = 0; - if (coin.x < offsetXRoundedDown + radius) { - coin.x = offsetXRoundedDown + radius; - coin.vx *= -1; - hhit = 1; - } - if (coin.y < radius) { - coin.y = radius; - coin.vy *= -1; - vhit = 1; - } - if (coin.x > gameCanvas.width - offsetXRoundedDown - radius) { - coin.x = gameCanvas.width - offsetXRoundedDown - radius; - coin.vx *= -1; - hhit = 1; - } + if (coin.x < offsetXRoundedDown + radius) { + coin.x = offsetXRoundedDown + radius; + coin.vx *= -1; + hhit = 1; + } + if (coin.y < radius) { + coin.y = radius; + coin.vy *= -1; + vhit = 1; + } + if (coin.x > gameCanvas.width - offsetXRoundedDown - radius) { + coin.x = gameCanvas.width - offsetXRoundedDown - radius; + coin.vx *= -1; + hhit = 1; + } - return hhit + vhit * 2; + return hhit + vhit * 2; } let lastTickDown = 0; function tick() { - recomputeTargetBaseSpeed(); - const currentTick = performance.now(); + recomputeTargetBaseSpeed(); + const currentTick = performance.now(); - puckWidth = - (gameZoneWidth / 12) * (3 - perks.smaller_puck + perks.bigger_puck); + puckWidth = + (gameZoneWidth / 12) * (3 - perks.smaller_puck + perks.bigger_puck); - if (keyboardPuckSpeed) { - setMousePos(puck + keyboardPuckSpeed); + if (keyboardPuckSpeed) { + setMousePos(puck + keyboardPuckSpeed); + } + + if (running) { + levelTime += currentTick - lastTick; + runStatistics.runTime += currentTick - lastTick; + runStatistics.max_combo = Math.max(runStatistics.max_combo, combo); + + // How many times to compute + let delta = Math.min(4, (currentTick - lastTick) / (1000 / 60)); + delta *= running ? 1 : 0; + + coins = coins.filter((coin) => !coin.destroyed); + balls = balls.filter((ball) => !ball.destroyed); + + const remainingBricks = bricks.filter((b) => b && b !== "black").length; + + if (levelTime > lastTickDown + 1000 && perks.hot_start) { + lastTickDown = levelTime; + decreaseCombo(perks.hot_start, puck, gameZoneHeight - 2 * puckHeight); } - if (running) { - levelTime += currentTick - lastTick; - runStatistics.runTime += currentTick - lastTick; - runStatistics.max_combo = Math.max(runStatistics.max_combo, combo); - - // How many times to compute - let delta = Math.min(4, (currentTick - lastTick) / (1000 / 60)); - delta *= running ? 1 : 0; - - coins = coins.filter((coin) => !coin.destroyed); - balls = balls.filter((ball) => !ball.destroyed); - - const remainingBricks = bricks.filter((b) => b && b !== "black").length; - - if (levelTime > lastTickDown + 1000 && perks.hot_start) { - lastTickDown = levelTime; - decreaseCombo(perks.hot_start, puck, gameZoneHeight - 2 * puckHeight); - } - - if (remainingBricks <= perks.skip_last) { - bricks.forEach((type, index) => { - if (type) { - explodeBrick(index, balls[0], true); - } - }); - } - if (!remainingBricks && !coins.length) { - if (currentLevel + 1 < max_levels()) { - setLevel(currentLevel + 1); - } else { - gameOver( - "Run finished with " + score + " points", - "You cleared all levels for this run.", - ); - } - } else if (running || levelTime) { - let playedCoinBounce = false; - const coinRadius = Math.round(coinSize / 2); - - coins.forEach((coin) => { - if (coin.destroyed) return; - if (perks.coin_magnet) { - coin.vx += - ((delta * (puck - coin.x)) / - (100 + - Math.pow(coin.y - gameZoneHeight, 2) + - Math.pow(coin.x - puck, 2))) * - perks.coin_magnet * - 100; - } - - const ratio = 1 - (perks.viscosity * 0.03 + 0.005) * delta; - - coin.vy *= ratio; - coin.vx *= ratio; - if (coin.vx > 7 * baseSpeed) coin.vx = 7 * baseSpeed; - if (coin.vx < -7 * baseSpeed) coin.vx = -7 * baseSpeed; - if (coin.vy > 7 * baseSpeed) coin.vy = 7 * baseSpeed; - if (coin.vy < -7 * baseSpeed) coin.vy = -7 * baseSpeed; - coin.a += coin.sa; - - // Gravity - coin.vy += delta * coin.weight * 0.8; - - const speed = Math.abs(coin.sx) + Math.abs(coin.sx); - const hitBorder = bordersHitCheck(coin, coinRadius, delta); - - if ( - coin.y > gameZoneHeight - coinRadius - puckHeight && - coin.y < gameZoneHeight + puckHeight + coin.vy && - Math.abs(coin.x - puck) < - coinRadius + - puckWidth / 2 + // a bit of margin to be nice - puckHeight - ) { - addToScore(coin); - } else if (coin.y > gameCanvas.height + coinRadius) { - coin.destroyed = true; - if (perks.compound_interest) { - resetCombo( - coin.x,coin.y - ); - } - } - - const hitBrick = coinBrickHitCheck(coin); - - if (perks.metamorphosis && typeof hitBrick !== "undefined") { - if ( - bricks[hitBrick] && - coin.color !== bricks[hitBrick] && - bricks[hitBrick] !== "black" && - !coin.coloredABrick - ) { - bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - } - } - if (typeof hitBrick !== "undefined" || hitBorder) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20 && !playedCoinBounce) { - playedCoinBounce = true; - sounds.coinBounce(coin.x, 0.2); - } - - if (Math.abs(coin.vy) < 3) { - coin.vy = 0; - } - } - }); - - balls.forEach((ball) => ballTick(ball, delta)); - - if (perks.wind) { - const windD = - ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * - 2 * - perks.wind; - for (let i = 0; i < perks.wind; i++) { - if (Math.random() * Math.abs(windD) > 0.5) { - flashes.push({ - type: "particle", - duration: 150, - ethereal: true, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, - y: Math.random() * gameZoneHeight, - vx: windD * 8, - vy: 0, - }); - } - } - } - - flashes.forEach((flash) => { - if (flash.type === "particle") { - flash.x += flash.vx * delta; - flash.y += flash.vy * delta; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) { - flash.destroyed = true; - } - } - } - }); - } - - if (combo > baseCombo()) { - // The red should still be visible on a white bg - const baseParticle = !isSettingOn("basic") && - (combo - baseCombo()) * Math.random() > 5 && - running && { - type: "particle" as FlashTypes, - duration: 100 * (Math.random() + 1), - time: levelTime, - size: coinSize / 2, - color: "red", - ethereal: true, - }; - - if (perks.top_is_lava) { - baseParticle && - flashes.push({ - ...baseParticle, - x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, - y: 0, - vx: (Math.random() - 0.5) * 10, - vy: 5, - }); - } - if (perks.sides_are_lava) { - const fromLeft = Math.random() > 0.5; - baseParticle && - flashes.push({ - ...baseParticle, - x: offsetXRoundedDown + (fromLeft ? 0 : gameZoneWidthRoundedUp), - y: Math.random() * gameZoneHeight, - vx: fromLeft ? 5 : -5, - vy: (Math.random() - 0.5) * 10, - }); - } - if (perks.compound_interest) { - let x = puck, attemps=0; - do { - x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math.random(); - attemps++ - } while (Math.abs(x - puck) < puckWidth / 2 && attemps<10); - baseParticle && - flashes.push({ - ...baseParticle, - x, - y: gameZoneHeight, - vx: (Math.random() - 0.5) * 10, - vy: -5, - }); - } - if (perks.streak_shots) { - const pos = 0.5 - Math.random(); - baseParticle && - flashes.push({ - ...baseParticle, - duration: 100, - x: puck + puckWidth * pos, - y: gameZoneHeight - puckHeight, - vx: pos * 10, - vy: -5, - }); - } + if (remainingBricks <= perks.skip_last && !level_skip_last_uses) { + bricks.forEach((type, index) => { + if (type) { + explodeBrick(index, balls[0], true); } + }); + level_skip_last_uses++ } - - render(); - - requestAnimationFrame(tick); - lastTick = currentTick; -} - -function isTelekinesisActive(ball:Ball) { - return perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; -} - -function ballTick(ball:Ball, delta:number) { - ball.previousvx = ball.vx; - ball.previousvy = ball.vy; - - let speedLimitDampener = - 1 + - perks.telekinesis + - perks.ball_repulse_ball + - perks.puck_repulse_ball + - perks.ball_attract_ball; - if (isTelekinesisActive(ball)) { - speedLimitDampener += 3; - ball.vx += ((puck - ball.x) / 1000) * delta * perks.telekinesis; - } - - if (ball.vx * ball.vx + ball.vy * ball.vy < baseSpeed * 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 * baseSpeed) { - ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; - } - - if (perks.ball_repulse_ball) { - for (let b2 of balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - repulse(ball, b2, perks.ball_repulse_ball, true); - } - } - if (perks.ball_attract_ball) { - for (let b2 of balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - attract(ball, b2, perks.ball_attract_ball); - } - } - if ( - perks.puck_repulse_ball && - Math.abs(ball.x - puck) < - puckWidth / 2 + (ballSize * (9 + perks.puck_repulse_ball)) / 10 - ) { - repulse( - ball, - { - x: puck, - y: gameZoneHeight, - }, - perks.puck_repulse_ball, - false, + if (!remainingBricks && !coins.length) { + if (currentLevel + 1 < max_levels()) { + setLevel(currentLevel + 1); + } else { + gameOver( + "Run finished with " + score + " points", + "You cleared all levels for this run.", ); - } + } + } else if (running || levelTime) { + let playedCoinBounce = false; + const coinRadius = Math.round(coinSize / 2); - if (perks.respawn && ball.hitItem?.length > 1 && !isSettingOn("basic")) { - for (let i = 0; i < ball.hitItem?.length - 1 && i < perks.respawn; i++) { - const {index, color} = ball.hitItem[i]; - if (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; - - flashes.push({ - type: "particle", - duration: 250, - ethereal: true, - time: levelTime, - size: coinSize / 2, - color, - x: brickCenterX(index) + (dx * brickWidth) / 2, - y: brickCenterY(index) + (dy * brickWidth) / 2, - vx: vertical ? 0 : -dx * baseSpeed, - vy: vertical ? -dy * baseSpeed : 0, - }); - } - } - - const borderHitCode = bordersHitCheck(ball, ballSize / 2, delta); - if (borderHitCode) { - if (perks.sides_are_lava && borderHitCode % 2) { - resetCombo(ball.x, ball.y); - } - if (perks.top_is_lava && borderHitCode >= 2) { - resetCombo(ball.x, ball.y + ballSize); - } - sounds.wallBeep(ball.x); - ball.bouncesList?.push({x: ball.previousx, y: ball.previousy}); - } - - // Puck collision - const ylimit = gameZoneHeight - puckHeight - ballSize / 2; - if ( - ball.y > ylimit && - Math.abs(ball.x - puck) < ballSize / 2 + puckWidth / 2 && - ball.vy > 0 - ) { - const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); - const angle = Math.atan2(-puckWidth / 2, ball.x - puck); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - - sounds.wallBeep(ball.x); - if (perks.streak_shots) { - resetCombo(ball.x, ball.y); + coins.forEach((coin) => { + if (coin.destroyed) return; + if (perks.coin_magnet) { + coin.vx += + ((delta * (puck - coin.x)) / + (100 + + Math.pow(coin.y - gameZoneHeight, 2) + + Math.pow(coin.x - puck, 2))) * + perks.coin_magnet * + 100; } - if (perks.respawn) { - ball.hitItem - .slice(0, -1) - .slice(0, perks.respawn) - .forEach(({index, color}) => { - if (!bricks[index] && color !== "black") bricks[index] = color; - }); - } - ball.hitItem = []; - if (!ball.hitSinceBounce) { - runStatistics.misses++; - levelMisses++; - resetCombo(ball.x, ball.y); - flashes.push({ - type: "text", - text: "miss", - duration: 500, - time: levelTime, - size: puckHeight * 1.5, - color: "red", - x: puck, - y: gameZoneHeight - puckHeight * 2, - }); - } - runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.sapperUses = 0; - ball.piercedSinceBounce = 0; - ball.bouncesList = [ - { - x: ball.previousx, - y: ball.previousy, - }, - ]; - } + const ratio = 1 - (perks.viscosity * 0.03 + 0.005) * delta; - if (ball.y > gameZoneHeight + ballSize / 2 && running) { - ball.destroyed = true; - runStatistics.balls_lost++; - if (!balls.find((b) => !b.destroyed)) { - if (perks.extra_life) { - perks.extra_life--; - resetBalls(); - sounds.revive(); - pause(false); - coins = []; - flashes.push({ - type: "ball", - duration: 500, - time: levelTime, - size: brickWidth * 2, - color: "white", - x: ball.x, - y: ball.y, - }); - } else { - gameOver( - "Game Over", - "You dropped the ball after catching " + score + " coins. ", - ); - } - } - } - const hitBrick = ballBrickHitCheck(ball); - if (typeof hitBrick !== "undefined") { - const initialBrickColor = bricks[hitBrick]; + coin.vy *= ratio; + coin.vx *= ratio; + if (coin.vx > 7 * baseSpeed) coin.vx = 7 * baseSpeed; + if (coin.vx < -7 * baseSpeed) coin.vx = -7 * baseSpeed; + if (coin.vy > 7 * baseSpeed) coin.vy = 7 * baseSpeed; + if (coin.vy < -7 * baseSpeed) coin.vy = -7 * baseSpeed; + coin.a += coin.sa; - explodeBrick(hitBrick, ball, false); + // Gravity + coin.vy += delta * coin.weight * 0.8; + + const speed = Math.abs(coin.sx) + Math.abs(coin.sx); + const hitBorder = bordersHitCheck(coin, coinRadius, delta); if ( - ball.sapperUses < perks.sapper && - initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !bricks[hitBrick] + coin.y > gameZoneHeight - coinRadius - puckHeight && + coin.y < gameZoneHeight + puckHeight + coin.vy && + Math.abs(coin.x - puck) < + coinRadius + + puckWidth / 2 + // a bit of margin to be nice + puckHeight ) { - bricks[hitBrick] = "black"; - ball.sapperUses++; + addToScore(coin); + } else if (coin.y > gameCanvas.height + coinRadius) { + coin.destroyed = true; + if (perks.compound_interest) { + resetCombo(coin.x, coin.y); + } } + + const hitBrick = coinBrickHitCheck(coin); + + if (perks.metamorphosis && typeof hitBrick !== "undefined") { + if ( + bricks[hitBrick] && + coin.color !== bricks[hitBrick] && + bricks[hitBrick] !== "black" && + !coin.coloredABrick + ) { + bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + } + } + if (typeof hitBrick !== "undefined" || hitBorder) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20 && !playedCoinBounce) { + playedCoinBounce = true; + sounds.coinBounce(coin.x, 0.2); + } + + if (Math.abs(coin.vy) < 3) { + coin.vy = 0; + } + } + }); + + balls.forEach((ball) => ballTick(ball, delta)); + + if (perks.wind) { + const windD = + ((puck - (offsetX + gameZoneWidth / 2)) / gameZoneWidth) * + 2 * + perks.wind; + for (let i = 0; i < perks.wind; i++) { + if (Math.random() * Math.abs(windD) > 0.5) { + flashes.push({ + type: "particle", + duration: 150, + ethereal: true, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, + y: Math.random() * gameZoneHeight, + vx: windD * 8, + vy: 0, + }); + } + } + } + + flashes.forEach((flash) => { + if (flash.type === "particle") { + flash.x += flash.vx * delta; + flash.y += flash.vy * delta; + if (!flash.ethereal) { + flash.vy += 0.5; + if (hasBrick(brickIndex(flash.x, flash.y))) { + flash.destroyed = true; + } + } + } + }); } - if (!isSettingOn("basic")) { - ball.sparks += (delta * (combo - 1)) / 30; - if (ball.sparks > 1) { - flashes.push({ - type: "particle", - duration: 100 * ball.sparks, - time: levelTime, - size: coinSize / 2, - color: ballsColor, - x: ball.x, - y: ball.y, - vx: (Math.random() - 0.5) * baseSpeed, - vy: (Math.random() - 0.5) * baseSpeed, - }); - ball.sparks = 0; - } + if (combo > baseCombo()) { + // The red should still be visible on a white bg + const baseParticle = !isSettingOn("basic") && + (combo - baseCombo()) * Math.random() > 5 && + running && { + type: "particle" as FlashTypes, + duration: 100 * (Math.random() + 1), + time: levelTime, + size: coinSize / 2, + color: "red", + ethereal: true, + }; + + if (perks.top_is_lava) { + baseParticle && + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown + Math.random() * gameZoneWidthRoundedUp, + y: 0, + vx: (Math.random() - 0.5) * 10, + vy: 5, + }); + } + + if (perks.left_is_lava && baseParticle) { + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown, + y: Math.random() * gameZoneHeight, + vx: 5, + vy: (Math.random() - 0.5) * 10, + }); + } + + if (perks.right_is_lava && baseParticle) { + flashes.push({ + ...baseParticle, + x: offsetXRoundedDown + gameZoneWidthRoundedUp, + y: Math.random() * gameZoneHeight, + vx: -5, + vy: (Math.random() - 0.5) * 10, + }); + } + + if (perks.compound_interest) { + let x = puck, + attemps = 0; + do { + x = offsetXRoundedDown + gameZoneWidthRoundedUp * Math.random(); + attemps++; + } while (Math.abs(x - puck) < puckWidth / 2 && attemps < 10); + baseParticle && + flashes.push({ + ...baseParticle, + x, + y: gameZoneHeight, + vx: (Math.random() - 0.5) * 10, + vy: -5, + }); + } + if (perks.streak_shots) { + const pos = 0.5 - Math.random(); + baseParticle && + flashes.push({ + ...baseParticle, + duration: 100, + x: puck + puckWidth * pos, + y: gameZoneHeight - puckHeight, + vx: pos * 10, + vy: -5, + }); + } } + } + + render(); + + requestAnimationFrame(tick); + lastTick = currentTick; } -const defaultRunStats = () => ({ +function isTelekinesisActive(ball: Ball) { + return perks.telekinesis && !ball.hitSinceBounce && ball.vy < 0; +} + +function ballTick(ball: Ball, delta: number) { + ball.previousvx = ball.vx; + ball.previousvy = ball.vy; + + let speedLimitDampener = + 1 + + perks.telekinesis + + perks.ball_repulse_ball + + perks.puck_repulse_ball + + perks.ball_attract_ball; + if (isTelekinesisActive(ball)) { + speedLimitDampener += 3; + ball.vx += ((puck - ball.x) / 1000) * delta * perks.telekinesis; + } + + if (ball.vx * ball.vx + ball.vy * ball.vy < baseSpeed * 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 * baseSpeed) { + ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; + } + + if (perks.ball_repulse_ball) { + for (let b2 of balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + repulse(ball, b2, perks.ball_repulse_ball, true); + } + } + if (perks.ball_attract_ball) { + for (let b2 of balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + attract(ball, b2, perks.ball_attract_ball); + } + } + if ( + perks.puck_repulse_ball && + Math.abs(ball.x - puck) < + puckWidth / 2 + (ballSize * (9 + perks.puck_repulse_ball)) / 10 + ) { + repulse( + ball, + { + x: puck, + y: gameZoneHeight, + }, + perks.puck_repulse_ball, + false, + ); + } + + if (perks.respawn && ball.hitItem?.length > 1 && !isSettingOn("basic")) { + for (let i = 0; i < ball.hitItem?.length - 1 && i < perks.respawn; i++) { + const { index, color } = ball.hitItem[i]; + if (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; + + flashes.push({ + type: "particle", + duration: 250, + ethereal: true, + time: levelTime, + size: coinSize / 2, + color, + x: brickCenterX(index) + (dx * brickWidth) / 2, + y: brickCenterY(index) + (dy * brickWidth) / 2, + vx: vertical ? 0 : -dx * baseSpeed, + vy: vertical ? -dy * baseSpeed : 0, + }); + } + } + + const borderHitCode = bordersHitCheck(ball, ballSize / 2, delta); + if (borderHitCode) { + if ( + perks.left_is_lava && + borderHitCode % 2 && + ball.x < offsetX + gameZoneWidth / 2 + ) { + resetCombo(ball.x, ball.y); + } + + if ( + perks.right_is_lava && + borderHitCode % 2 && + ball.x > offsetX + gameZoneWidth / 2 + ) { + resetCombo(ball.x, ball.y); + } + + if (perks.top_is_lava && borderHitCode >= 2) { + resetCombo(ball.x, ball.y + ballSize); + } + sounds.wallBeep(ball.x); + ball.bouncesList?.push({ x: ball.previousx, y: ball.previousy }); + } + + // Puck collision + const ylimit = gameZoneHeight - puckHeight - ballSize / 2; + if ( + ball.y > ylimit && + Math.abs(ball.x - puck) < ballSize / 2 + puckWidth / 2 && + ball.vy > 0 + ) { + const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); + const angle = Math.atan2(-puckWidth / 2, ball.x - puck); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + + sounds.wallBeep(ball.x); + if (perks.streak_shots) { + resetCombo(ball.x, ball.y); + } + + if (perks.respawn) { + ball.hitItem + .slice(0, -1) + .slice(0, perks.respawn) + .forEach(({ index, color }) => { + if (!bricks[index] && color !== "black") bricks[index] = color; + }); + } + ball.hitItem = []; + if (!ball.hitSinceBounce) { + runStatistics.misses++; + levelMisses++; + resetCombo(ball.x, ball.y); + flashes.push({ + type: "text", + text: "miss", + duration: 500, + time: levelTime, + size: puckHeight * 1.5, + color: "red", + x: puck, + y: gameZoneHeight - puckHeight * 2, + }); + } + runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.sapperUses = 0; + ball.piercedSinceBounce = 0; + ball.bouncesList = [ + { + x: ball.previousx, + y: ball.previousy, + }, + ]; + } + + if (ball.y > gameZoneHeight + ballSize / 2 && running) { + ball.destroyed = true; + runStatistics.balls_lost++; + if (!balls.find((b) => !b.destroyed)) { + if (perks.extra_life) { + perks.extra_life--; + resetBalls(); + sounds.revive(); + pause(false); + coins = []; + flashes.push({ + type: "ball", + duration: 500, + time: levelTime, + size: brickWidth * 2, + color: "white", + x: ball.x, + y: ball.y, + }); + } else { + gameOver( + "Game Over", + "You dropped the ball after catching " + score + " coins. ", + ); + } + } + } + const hitBrick = ballBrickHitCheck(ball); + if (typeof hitBrick !== "undefined") { + const initialBrickColor = bricks[hitBrick]; + + explodeBrick(hitBrick, ball, false); + + if ( + ball.sapperUses < perks.sapper && + initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks + !bricks[hitBrick] + ) { + bricks[hitBrick] = "black"; + ball.sapperUses++; + } + } + + if (!isSettingOn("basic")) { + ball.sparks += (delta * (combo - 1)) / 30; + if (ball.sparks > 1) { + flashes.push({ + type: "particle", + duration: 100 * ball.sparks, + time: levelTime, + size: coinSize / 2, + color: ballsColor, + x: ball.x, + y: ball.y, + vx: (Math.random() - 0.5) * baseSpeed, + vy: (Math.random() - 0.5) * baseSpeed, + }); + ball.sparks = 0; + } + } +} + +const defaultRunStats = () => + ({ started: Date.now(), levelsPlayed: 0, runTime: 0, @@ -1235,1460 +1288,1577 @@ const defaultRunStats = () => ({ upgrades_picked: 1, max_combo: 1, max_level: 0, -}) as RunStats; + }) as RunStats; let runStatistics = defaultRunStats(); function resetRunStatistics() { - runStatistics = defaultRunStats(); + runStatistics = defaultRunStats(); } function getTotalScore() { - try { - return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); - } catch (e) { - return 0; - } + try { + return JSON.parse(localStorage.getItem("breakout_71_total_score") || "0"); + } catch (e) { + return 0; + } } -function addToTotalScore(points:number) { - try { - localStorage.setItem( - "breakout_71_total_score", - JSON.stringify(getTotalScore() + points), - ); - } catch (e) { - } +function addToTotalScore(points: number) { + if (ignoreThisRunInStats) return; + try { + localStorage.setItem( + "breakout_71_total_score", + JSON.stringify(getTotalScore() + points), + ); + } catch (e) {} } -function addToTotalPlayTime(ms:number) { - try { - localStorage.setItem( - "breakout_71_total_play_time", - JSON.stringify( - JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + - ms, - ), - ); - } catch (e) { - } +function addToTotalPlayTime(ms: number) { + try { + localStorage.setItem( + "breakout_71_total_play_time", + JSON.stringify( + JSON.parse(localStorage.getItem("breakout_71_total_play_time") || "0") + + ms, + ), + ); + } catch (e) {} } -function gameOver(title:string, intro:string) { - if (!running) return; - pause(true); - stopRecording(); - addToTotalPlayTime(runStatistics.runTime); - runStatistics.max_level = currentLevel + 1; +function gameOver(title: string, intro: string) { + if (!running) return; + pause(true); + stopRecording(); + addToTotalPlayTime(runStatistics.runTime); + runStatistics.max_level = currentLevel + 1; - let animationDelay = -300; - const getDelay = () => { - animationDelay += 800; - return "animation-delay:" + animationDelay + "ms;"; - }; - // unlocks - let unlocksInfo = ""; - const endTs = getTotalScore(); - const startTs = endTs - score; - const list = getUpgraderUnlockPoints(); - list - .filter((u) => u.threshold > startTs && u.threshold < endTs) - .forEach((u) => { - unlocksInfo += ` + let animationDelay = -300; + const getDelay = () => { + animationDelay += 800; + return "animation-delay:" + animationDelay + "ms;"; + }; + // unlocks + let unlocksInfo = ""; + const endTs = getTotalScore(); + const startTs = endTs - score; + const list = getUpgraderUnlockPoints(); + list + .filter((u) => u.threshold > startTs && u.threshold < endTs) + .forEach((u) => { + unlocksInfo += `

${u.title}

`; - }); - const previousUnlockAt = - findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; - const nextUnlock = list.find((u) => u.threshold > endTs); + }); + const previousUnlockAt = + findLast(list, (u) => u.threshold <= endTs)?.threshold || 0; + const nextUnlock = list.find((u) => u.threshold > endTs); - if (nextUnlock) { - const total = nextUnlock?.threshold - previousUnlockAt; - const done = endTs - previousUnlockAt; - intro += `Score ${nextUnlock.threshold - endTs} more points to reach the next unlock.`; + if (nextUnlock) { + const total = nextUnlock?.threshold - previousUnlockAt; + const done = endTs - previousUnlockAt; + intro += `Score ${nextUnlock.threshold - endTs} more points to reach the next unlock.`; - const scaleX = (done / total).toFixed(2); - unlocksInfo += ` + const scaleX = (done / total).toFixed(2); + unlocksInfo += `

${nextUnlock.title}

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

${u.title}

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

This test run and its score are not being recorded

" : ""}

${intro}

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

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

+ return `

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

${bars}
`; - }; + }; - runStats += makeHistogram("Total score", (r) => r.score, ""); - runStats += makeHistogram( - "Catch rate", - (r) => Math.round((r.score / r.coins_spawned) * 100), - "%", - ); - runStats += makeHistogram("Bricks broken", (r) => r.bricks_broken, ""); - runStats += makeHistogram( - "Bricks broken per minute", - (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), - " bpm", - ); - runStats += makeHistogram( - "Hit rate", - (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), - "%", - ); - runStats += makeHistogram( - "Duration per level", - (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), - "s", - ); - runStats += makeHistogram("Level reached", (r) => r.levelsPlayed, ""); - runStats += makeHistogram("Upgrades applied", (r) => r.upgrades_picked, ""); - runStats += makeHistogram("Balls lost", (r) => r.balls_lost, ""); - runStats += makeHistogram( - "Average combo", - (r) => Math.round(r.coins_spawned / r.bricks_broken), - "", - ); - runStats += makeHistogram("Max combo", (r) => r.max_combo, ""); + runStats += makeHistogram("Total score", (r) => r.score, ""); + runStats += makeHistogram( + "Catch rate", + (r) => Math.round((r.score / r.coins_spawned) * 100), + "%", + ); + runStats += makeHistogram("Bricks broken", (r) => r.bricks_broken, ""); + runStats += makeHistogram( + "Bricks broken per minute", + (r) => Math.round((r.bricks_broken / r.runTime) * 1000 * 60), + " bpm", + ); + runStats += makeHistogram( + "Hit rate", + (r) => Math.round((1 - r.misses / r.puck_bounces) * 100), + "%", + ); + runStats += makeHistogram( + "Duration per level", + (r) => Math.round(r.runTime / 1000 / r.levelsPlayed), + "s", + ); + runStats += makeHistogram("Level reached", (r) => r.levelsPlayed, ""); + runStats += makeHistogram("Upgrades applied", (r) => r.upgrades_picked, ""); + runStats += makeHistogram("Balls lost", (r) => r.balls_lost, ""); + runStats += makeHistogram( + "Average combo", + (r) => Math.round(r.coins_spawned / r.bricks_broken), + "", + ); + runStats += makeHistogram("Max combo", (r) => r.max_combo, ""); - if (runStats) { - runStats = - `

Find below your run statistics compared to your ${runsHistory.length - 1} best runs.

` + - runStats; - } - } catch (e) { - console.warn(e); + if (runStats) { + runStats = + `

Find below your run statistics compared to your ${runsHistory.length - 1} best runs.

` + + runStats; } - return runStats; + } catch (e) { + console.warn(e); + } + return runStats; } -function explodeBrick(index:number, ball:Ball, isExplosion:boolean) { - const color = bricks[index]; - if (!color) return; +function explodeBrick(index: number, ball: Ball, isExplosion: boolean) { + const color = bricks[index]; + if (!color) return; - if (color === "black") { - delete bricks[index]; - const x = brickCenterX(index), - y = brickCenterY(index); + if (color === "black") { + delete bricks[index]; + const x = brickCenterX(index), + y = brickCenterY(index); - sounds.explode(ball.x); + sounds.explode(ball.x); - const col = index % gridSize; - const row = Math.floor(index / gridSize); - const size = 1 + perks.bigger_explosions; - // Break bricks around - for (let dx = -size; dx <= size; dx++) { - for (let dy = -size; dy <= size; dy++) { - const i = getRowColIndex(row + dy, col + dx); - if (bricks[i] && i !== -1) { - explodeBrick(i, ball, true); - } - } + const col = index % gridSize; + const row = Math.floor(index / gridSize); + const size = 1 + perks.bigger_explosions; + // Break bricks around + for (let dx = -size; dx <= size; dx++) { + for (let dy = -size; dy <= size; dy++) { + const i = getRowColIndex(row + dy, col + dx); + if (bricks[i] && i !== -1) { + explodeBrick(i, ball, true); } - - // Blow nearby coins - coins.forEach((c) => { - const dx = c.x - x; - const dy = c.y - y; - const d2 = Math.max(brickWidth, Math.abs(dx) + Math.abs(dy)); - c.vx += ((dx / d2) * 10 * size) / c.weight; - c.vy += ((dy / d2) * 10 * size) / c.weight; - }); - lastExplosion = Date.now(); - - flashes.push({ - type: "ball", - duration: 150, - time: levelTime, - size: brickWidth * 2, - color: "white", - x, - y, - }); - spawnExplosion( - 7 * (1 + perks.bigger_explosions), - x, - y, - "white", - 150, - coinSize, - ); - ball.hitSinceBounce++; - runStatistics.bricks_broken++; - } else if (color) { - // Even if it bounces we don't want to count that as a miss - ball.hitSinceBounce++; - - if (perks.sturdy_bricks && perks.sturdy_bricks > Math.random() * 5) { - // Resist - sounds.coinBounce(ball.x, 1); - return; - } - // Flashing is take care of by the tick loop - const x = brickCenterX(index), - y = brickCenterY(index); - - bricks[index] = ""; - - // coins = coins.filter((c) => !c.destroyed); - let coinsToSpawn = combo; - if (perks.sturdy_bricks) { - // +10% per level - coinsToSpawn += Math.ceil( - ((10 + perks.sturdy_bricks) / 10) * coinsToSpawn, - ); - } - - levelSpawnedCoins += coinsToSpawn; - runStatistics.coins_spawned += coinsToSpawn; - runStatistics.bricks_broken++; - const maxCoins = MAX_COINS * (isSettingOn("basic") ? 0.5 : 1); - const spawnableCoins = - coins.length > MAX_COINS ? 1 : Math.floor(maxCoins - coins.length) / 3; - - const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); - - while (coinsToSpawn > 0) { - const points = Math.min(pointsPerCoin, coinsToSpawn); - if (points < 0 || isNaN(points)) { - console.error({points}); - debugger; - } - - coinsToSpawn -= points; - - const cx = x + (Math.random() - 0.5) * (brickWidth - coinSize), - cy = y + (Math.random() - 0.5) * (brickWidth - coinSize); - - coins.push({ - points, - color: perks.metamorphosis ? color : "gold", - x: cx, - y: cy, - previousx: cx, - previousy: cy, - // Use previous speed because the ball has already bounced - vx: ball.previousvx * (0.5 + Math.random()), - vy: ball.previousvy * (0.5 + Math.random()), - sx: 0, - sy: 0, - a: Math.random() * Math.PI * 2, - sa: Math.random() - 0.5, - weight: 0.8 + Math.random() * 0.2, - }); - } - - combo += Math.max( - 0, - perks.streak_shots + - perks.compound_interest + - perks.sides_are_lava + - perks.top_is_lava + - perks.picky_eater - - Math.round(Math.random() * perks.soft_reset), - ); - - if (!isExplosion) { - // color change - if ( - (perks.picky_eater || perks.pierce_color) && - color !== ballsColor && - color - ) { - if (perks.picky_eater) { - resetCombo(ball.x, ball.y); - } - - ballsColor = color; - } else { - sounds.comboIncreaseMaybe(ball.x, 1); - } - } - - flashes.push({ - type: "ball", - duration: 40, - time: levelTime, - size: brickWidth, - color: color, - x, - y, - }); - spawnExplosion(5 + Math.min(combo, 30), x, y, color, 150, coinSize / 2); + } } - if (!bricks[index] && color!=='black') { - ball.hitItem?.push({ - index, - color, - }); + // Blow nearby coins + coins.forEach((c) => { + const dx = c.x - x; + const dy = c.y - y; + const d2 = Math.max(brickWidth, Math.abs(dx) + Math.abs(dy)); + c.vx += ((dx / d2) * 10 * size) / c.weight; + c.vy += ((dy / d2) * 10 * size) / c.weight; + }); + lastExplosion = Date.now(); + + flashes.push({ + type: "ball", + duration: 150, + time: levelTime, + size: brickWidth * 2, + color: "white", + x, + y, + }); + spawnExplosion( + 7 * (1 + perks.bigger_explosions), + x, + y, + "white", + 150, + coinSize, + ); + ball.hitSinceBounce++; + runStatistics.bricks_broken++; + } else if (color) { + // Even if it bounces we don't want to count that as a miss + ball.hitSinceBounce++; + + if (perks.sturdy_bricks && perks.sturdy_bricks > Math.random() * 5) { + // Resist + sounds.coinBounce(ball.x, 1); + return; } + // Flashing is take care of by the tick loop + const x = brickCenterX(index), + y = brickCenterY(index); + + bricks[index] = ""; + + // coins = coins.filter((c) => !c.destroyed); + let coinsToSpawn = combo; + if (perks.sturdy_bricks) { + // +10% per level + coinsToSpawn += Math.ceil( + ((10 + perks.sturdy_bricks) / 10) * coinsToSpawn, + ); + } + + levelSpawnedCoins += coinsToSpawn; + runStatistics.coins_spawned += coinsToSpawn; + runStatistics.bricks_broken++; + const maxCoins = MAX_COINS * (isSettingOn("basic") ? 0.5 : 1); + const spawnableCoins = + coins.length > MAX_COINS ? 1 : Math.floor(maxCoins - coins.length) / 3; + + const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); + + while (coinsToSpawn > 0) { + const points = Math.min(pointsPerCoin, coinsToSpawn); + if (points < 0 || isNaN(points)) { + console.error({ points }); + debugger; + } + + coinsToSpawn -= points; + + const cx = x + (Math.random() - 0.5) * (brickWidth - coinSize), + cy = y + (Math.random() - 0.5) * (brickWidth - coinSize); + + coins.push({ + points, + color: perks.metamorphosis ? color : "gold", + x: cx, + y: cy, + previousx: cx, + previousy: cy, + // Use previous speed because the ball has already bounced + vx: ball.previousvx * (0.5 + Math.random()), + vy: ball.previousvy * (0.5 + Math.random()), + sx: 0, + sy: 0, + a: Math.random() * Math.PI * 2, + sa: Math.random() - 0.5, + weight: 0.8 + Math.random() * 0.2, + }); + } + + combo += Math.max( + 0, + perks.streak_shots + + perks.compound_interest + + perks.left_is_lava + + perks.right_is_lava + + perks.top_is_lava + + perks.picky_eater - + Math.round(Math.random() * perks.soft_reset), + ); + + if (!isExplosion) { + // color change + if ( + (perks.picky_eater || perks.pierce_color) && + color !== ballsColor && + color + ) { + if (perks.picky_eater) { + resetCombo(ball.x, ball.y); + } + + ballsColor = color; + } else { + sounds.comboIncreaseMaybe(ball.x, 1); + } + } + + flashes.push({ + type: "ball", + duration: 40, + time: levelTime, + size: brickWidth, + color: color, + x, + y, + }); + spawnExplosion(5 + Math.min(combo, 30), x, y, color, 150, coinSize / 2); + } + + if (!bricks[index] && color !== "black") { + ball.hitItem?.push({ + index, + color, + }); + } } function max_levels() { - return 7 + perks.extra_levels; + return 7 + perks.extra_levels; } function render() { - if (running) needsRender = true; - if (!needsRender) { - return; - } - needsRender = false; + if (running) needsRender = true; + if (!needsRender) { + return; + } + needsRender = false; - const level = currentLevelInfo(); - const {width, height} = gameCanvas; - if (!width || !height) return; + const level = currentLevelInfo(); + const { width, height } = gameCanvas; + if (!width || !height) return; - let scoreInfo = ""; - for (let i = 0; i < perks.extra_life; i++) { - scoreInfo += "🖤 "; - } + let scoreInfo = ""; + for (let i = 0; i < perks.extra_life; i++) { + scoreInfo += "🖤 "; + } - scoreInfo += "L" + (currentLevel + 1) + "/" + max_levels() + " "; - scoreInfo += "$" + score.toString(); + scoreInfo += "L" + (currentLevel + 1) + "/" + max_levels() + " "; + scoreInfo += "$" + score.toString(); - scoreDisplay.innerText = scoreInfo; - // Clear - if (!isSettingOn("basic") && !level.color && level.svg) { - // Without this the light trails everything - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = 0.4; - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - - ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = 0.6; - coins.forEach((coin) => { - if (!coin.destroyed) - drawFuzzyBall(ctx, coin.color, coinSize * 2, coin.x, coin.y); - }); - balls.forEach((ball) => { - drawFuzzyBall(ctx, ballsColor, ballSize * 2, ball.x, ball.y); - }); - ctx.globalAlpha = 0.5; - bricks.forEach((color, index) => { - if (!color) return; - const x = brickCenterX(index), - y = brickCenterY(index); - drawFuzzyBall(ctx, color == "black" ? "#666" : color, brickWidth, x, y); - }); - ctx.globalAlpha = 1; - flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "ball") { - drawFuzzyBall(ctx, color, size, x, y); - } - if (type === "particle") { - drawFuzzyBall(ctx, color, size * 3, x, y); - } - }); - // Decides how brights the bg black parts can get - ctx.globalAlpha = 0.2; - ctx.globalCompositeOperation = "multiply"; - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, width, height); - // Decides how dark the background black parts are when lit (1=black) - ctx.globalAlpha = 0.8; - ctx.globalCompositeOperation = "multiply"; - if (level.svg && background.width && background.complete) { - if (backgroundCanvas.title !== level.name) { - backgroundCanvas.title = level.name; - backgroundCanvas.width = gameCanvas.width; - backgroundCanvas.height = gameCanvas.height; - const bgctx = backgroundCanvas.getContext( - "2d", - ) as CanvasRenderingContext2D; - bgctx.fillStyle = level.color || "#000"; - bgctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height); - bgctx.fillStyle = ctx.createPattern(background, "repeat"); - bgctx.fillRect(0, 0, width, height); - } - - ctx.drawImage(backgroundCanvas, 0, 0); - } else { - // Background not loaded yes - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - } - } else { - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = level.color || "#000"; - ctx.fillRect(0, 0, width, height); - - flashes.forEach((flash) => { - const {x, y, time, color, size, type, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - if (type === "particle") { - drawBall(ctx, color, size, x, y); - } - }); - } - - ctx.globalAlpha = 1; + scoreDisplay.innerText = scoreInfo; + // Clear + if (!isSettingOn("basic") && !level.color && level.svg) { + // Without this the light trails everything ctx.globalCompositeOperation = "source-over"; - const lastExplosionDelay = Date.now() - lastExplosion + 5; - const shaked = lastExplosionDelay < 200; - if (shaked) { - const amplitude = ((perks.bigger_explosions + 1) * 50) / lastExplosionDelay; - ctx.translate( - Math.sin(Date.now()) * amplitude, - Math.sin(Date.now() + 36) * amplitude, - ); - } - - ctx.globalCompositeOperation = "source-over"; - renderAllBricks(); + ctx.globalAlpha = 0.4; + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "screen"; - flashes = flashes.filter( - (f) => levelTime - f.time < f.duration && !f.destroyed, - ); + ctx.globalAlpha = 0.6; + coins.forEach((coin) => { + if (!coin.destroyed) + drawFuzzyBall(ctx, coin.color, coinSize * 2, coin.x, coin.y); + }); + balls.forEach((ball) => { + drawFuzzyBall(ctx, ballsColor, ballSize * 2, ball.x, ball.y); + }); + ctx.globalAlpha = 0.5; + bricks.forEach((color, index) => { + if (!color) return; + const x = brickCenterX(index), + y = brickCenterY(index); + drawFuzzyBall(ctx, color == "black" ? "#666" : color, brickWidth, x, y); + }); + ctx.globalAlpha = 1; + flashes.forEach((flash) => { + const { x, y, time, color, size, type, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "ball") { + drawFuzzyBall(ctx, color, size, x, y); + } + if (type === "particle") { + drawFuzzyBall(ctx, color, size * 3, x, y); + } + }); + // Decides how brights the bg black parts can get + ctx.globalAlpha = 0.2; + ctx.globalCompositeOperation = "multiply"; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, width, height); + // Decides how dark the background black parts are when lit (1=black) + ctx.globalAlpha = 0.8; + ctx.globalCompositeOperation = "multiply"; + if (level.svg && background.width && background.complete) { + if (backgroundCanvas.title !== level.name) { + backgroundCanvas.title = level.name; + backgroundCanvas.width = gameCanvas.width; + backgroundCanvas.height = gameCanvas.height; + const bgctx = backgroundCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D; + bgctx.fillStyle = level.color || "#000"; + bgctx.fillRect(0, 0, gameCanvas.width, gameCanvas.height); + bgctx.fillStyle = ctx.createPattern(background, "repeat"); + bgctx.fillRect(0, 0, width, height); + } + + ctx.drawImage(backgroundCanvas, 0, 0); + } else { + // Background not loaded yes + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); + } + } else { + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = level.color || "#000"; + ctx.fillRect(0, 0, width, height); flashes.forEach((flash) => { - const {x, y, time, color, size, type, text, duration} = flash; - const elapsed = levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); - if (type === "text") { - ctx.globalCompositeOperation = "source-over"; - drawText(ctx, text, color, size, x, y - elapsed / 10); - } else if (type === "particle") { - ctx.globalCompositeOperation = "screen"; - drawBall(ctx, color, size, x, y); - drawFuzzyBall(ctx, color, size, x, y); - } + const { x, y, time, color, size, type, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + if (type === "particle") { + drawBall(ctx, color, size, x, y); + } }); + } - // Coins - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - coins.forEach((coin) => { - if (!coin.destroyed) - drawCoin( - ctx, - coin.color, - coinSize, - coin.x, - coin.y, - level.color || "black", - coin.a, - ); - }); + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + const lastExplosionDelay = Date.now() - lastExplosion + 5; + const shaked = lastExplosionDelay < 200; + if (shaked) { + const amplitude = ((perks.bigger_explosions + 1) * 50) / lastExplosionDelay; + ctx.translate( + Math.sin(Date.now()) * amplitude, + Math.sin(Date.now() + 36) * amplitude, + ); + } - // Black shadow around balls - if (coins.length > 10 && !isSettingOn("basic")) { - ctx.globalAlpha = Math.min(0.8, (coins.length - 10) / 50); - balls.forEach((ball) => { - drawBall(ctx, level.color || "#000", ballSize * 6, ball.x, ball.y); - }); + ctx.globalCompositeOperation = "source-over"; + renderAllBricks(); + + ctx.globalCompositeOperation = "screen"; + flashes = flashes.filter( + (f) => levelTime - f.time < f.duration && !f.destroyed, + ); + + flashes.forEach((flash) => { + const { x, y, time, color, size, type, text, duration } = flash; + const elapsed = levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); + if (type === "text") { + ctx.globalCompositeOperation = "source-over"; + drawText(ctx, text, color, size, x, y - elapsed / 10); + } else if (type === "particle") { + ctx.globalCompositeOperation = "screen"; + drawBall(ctx, color, size, x, y); + drawFuzzyBall(ctx, color, size, x, y); } + }); - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; + // Coins + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + coins.forEach((coin) => { + if (!coin.destroyed) + drawCoin( + ctx, + coin.color, + coinSize, + coin.x, + coin.y, + level.color || "black", + coin.a, + ); + }); + + // Black shadow around balls + if (coins.length > 10 && !isSettingOn("basic")) { + ctx.globalAlpha = Math.min(0.8, (coins.length - 10) / 50); balls.forEach((ball) => { - // The white border around is to distinguish colored balls from coins/bg - drawBall(ctx, ballsColor, ballSize, ball.x, ball.y, puckColor); - - if (isTelekinesisActive(ball)) { - ctx.strokeStyle = puckColor; - ctx.beginPath(); - ctx.bezierCurveTo(puck, gameZoneHeight, puck, ball.y, ball.x, ball.y); - ctx.stroke(); - } + drawBall(ctx, level.color || "#000", ballSize * 6, ball.x, ball.y); }); - // The puck - ctx.globalAlpha = 1; + } + + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + balls.forEach((ball) => { + // The white border around is to distinguish colored balls from coins/bg + drawBall(ctx, ballsColor, ballSize, ball.x, ball.y, puckColor); + + if (isTelekinesisActive(ball)) { + ctx.strokeStyle = puckColor; + ctx.beginPath(); + ctx.bezierCurveTo(puck, gameZoneHeight, puck, ball.y, ball.x, ball.y); + ctx.stroke(); + } + }); + // The puck + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + if (perks.streak_shots && combo > baseCombo()) { + drawPuck(ctx, "red", puckWidth, puckHeight, -2); + } + drawPuck(ctx, puckColor, puckWidth, puckHeight); + + if (combo > 1) { ctx.globalCompositeOperation = "source-over"; - if (perks.streak_shots && combo > baseCombo()) { - drawPuck(ctx, "red", puckWidth, puckHeight, -2); + const comboText = "x " + combo; + const comboTextWidth = (comboText.length * puckHeight) / 1.8; + const totalWidth = comboTextWidth + coinSize * 2; + const left = puck - totalWidth / 2; + if (totalWidth < puckWidth) { + drawCoin( + ctx, + "gold", + coinSize, + left + coinSize / 2, + gameZoneHeight - puckHeight / 2, + puckColor, + 0, + ); + drawText( + ctx, + comboText, + "#000", + puckHeight, + left + coinSize * 1.5, + gameZoneHeight - puckHeight / 2, + true, + ); + } else { + drawText( + ctx, + comboText, + "#FFF", + puckHeight, + puck, + gameZoneHeight - puckHeight / 2, + false, + ); } - drawPuck(ctx, puckColor, puckWidth, puckHeight); + } + // Borders + const hasCombo = combo > baseCombo(); + ctx.globalCompositeOperation = "source-over"; + if (offsetXRoundedDown) { + // draw outside of gaming area to avoid capturing borders in recordings + ctx.fillStyle = hasCombo && perks.left_is_lava ? "red" : puckColor; + ctx.fillRect(offsetX - 1, 0, 1, height); + ctx.fillStyle = hasCombo && perks.right_is_lava ? "red" : puckColor; + ctx.fillRect(width - offsetX + 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height); + } - if (combo > 1) { - ctx.globalCompositeOperation = "source-over"; - const comboText = "x " + combo; - const comboTextWidth = (comboText.length * puckHeight) / 1.8; - const totalWidth = comboTextWidth + coinSize * 2; - const left = puck - totalWidth / 2; - if (totalWidth < puckWidth) { - drawCoin( - ctx, - "gold", - coinSize, - left + coinSize / 2, - gameZoneHeight - puckHeight / 2, - puckColor, - 0, - ); - drawText( - ctx, - comboText, - "#000", - puckHeight, - left + coinSize * 1.5, - gameZoneHeight - puckHeight / 2, - true, - ); - } else { - drawText( - ctx, - comboText, - "#FFF", - puckHeight, - puck, - gameZoneHeight - puckHeight / 2, - false, - ); - } - } - // Borders - const redSides = perks.sides_are_lava && combo > baseCombo(); - ctx.fillStyle = redSides ? "red" : puckColor; - ctx.globalCompositeOperation = "source-over"; - if (offsetXRoundedDown) { - // draw outside of gaming area to avoid capturing borders in recordings - ctx.fillRect(offsetX - 1, 0, 1, height); - ctx.fillRect(width - offsetX + 1, 0, 1, height); - } else if (redSides) { - ctx.fillRect(0, 0, 1, height); - ctx.fillRect(width - 1, 0, 1, height); + if (perks.top_is_lava && combo > baseCombo()) + drawRedSquare(ctx, offsetXRoundedDown, 0, gameZoneWidthRoundedUp, 1); + const redBottom = perks.compound_interest && combo > baseCombo(); + ctx.fillStyle = redBottom ? "red" : puckColor; + if (isSettingOn("mobile-mode")) { + ctx.fillRect(offsetXRoundedDown, gameZoneHeight, gameZoneWidthRoundedUp, 1); + if (!running) { + drawText( + ctx, + "Press and hold here to play", + puckColor, + puckHeight, + gameCanvas.width / 2, + gameZoneHeight + (gameCanvas.height - gameZoneHeight) / 2, + ); } + } else if (redBottom) { + ctx.fillRect( + offsetXRoundedDown, + gameZoneHeight - 1, + gameZoneWidthRoundedUp, + 1, + ); + } - if (perks.top_is_lava && combo > baseCombo()) - drawRedSquare(ctx, offsetXRoundedDown, 0, gameZoneWidthRoundedUp, 1); - const redBottom = perks.compound_interest && combo > baseCombo(); - ctx.fillStyle = redBottom ? "red" : puckColor; - if (isSettingOn("mobile-mode")) { - ctx.fillRect(offsetXRoundedDown, gameZoneHeight, gameZoneWidthRoundedUp, 1); - if (!running) { - drawText( - ctx, - "Press and hold here to play", - puckColor, - puckHeight, - gameCanvas.width / 2, - gameZoneHeight + (gameCanvas.height - gameZoneHeight) / 2, - ); - } - } else if (redBottom) { - ctx.fillRect( - offsetXRoundedDown, - gameZoneHeight - 1, - gameZoneWidthRoundedUp, - 1, - ); - } + if (shaked) { + ctx.resetTransform(); + } - if (shaked) { - ctx.resetTransform(); - } - - recordOneFrame(); + recordOneFrame(); } let cachedBricksRender = document.createElement("canvas"); let cachedBricksRenderKey = null; function renderAllBricks() { - ctx.globalAlpha = 1; + ctx.globalAlpha = 1; - const redBorderOnBricksWithWrongColor = - combo > baseCombo() && perks.picky_eater; + const redBorderOnBricksWithWrongColor = + combo > baseCombo() && perks.picky_eater; - const newKey = - gameZoneWidth + - "_" + - bricks.join("_") + - bombSVG.complete + - "_" + - redBorderOnBricksWithWrongColor + - "_" + - ballsColor; - if (newKey !== cachedBricksRenderKey) { - cachedBricksRenderKey = newKey; + const newKey = + gameZoneWidth + + "_" + + bricks.join("_") + + bombSVG.complete + + "_" + + redBorderOnBricksWithWrongColor + + "_" + + ballsColor+'_'+perks.pierce_color; + if (newKey !== cachedBricksRenderKey) { + cachedBricksRenderKey = newKey; - cachedBricksRender.width = gameZoneWidth; - cachedBricksRender.height = gameZoneWidth + 1; - const canctx = cachedBricksRender.getContext("2d") as CanvasRenderingContext2D; - canctx.clearRect(0, 0, gameZoneWidth, gameZoneWidth); - canctx.resetTransform(); - canctx.translate(-offsetX, 0); - // Bricks - bricks.forEach((color, index) => { - const x = brickCenterX(index), - y = brickCenterY(index); + cachedBricksRender.width = gameZoneWidth; + cachedBricksRender.height = gameZoneWidth + 1; + const canctx = cachedBricksRender.getContext( + "2d", + ) as CanvasRenderingContext2D; + canctx.clearRect(0, 0, gameZoneWidth, gameZoneWidth); + canctx.resetTransform(); + canctx.translate(-offsetX, 0); + // Bricks + bricks.forEach((color, index) => { + const x = brickCenterX(index), + y = brickCenterY(index); - if (!color) return; - const borderColor = - (ballsColor === color && puckColor) || - (color !== "black" && redBorderOnBricksWithWrongColor && "red") || - color; - drawBrick(canctx, color, borderColor, x, y); - if (color === "black") { - canctx.globalCompositeOperation = "source-over"; - drawIMG(canctx, bombSVG, brickWidth, x, y); - } - }); - } + if (!color) return; + + canctx.globalAlpha = (perks.pierce_color && ballsColor === color && 0.6) || 1; - ctx.drawImage(cachedBricksRender, offsetX, 0); + const borderColor = (ballsColor !== color && color !== "black" && redBorderOnBricksWithWrongColor && "red") || + color; + + drawBrick(canctx, color, borderColor, x, y); + if (color === "black") { + canctx.globalCompositeOperation = "source-over"; + drawIMG(canctx, bombSVG, brickWidth, x, y); + } + }); + } + + ctx.drawImage(cachedBricksRender, offsetX, 0); } let cachedGraphics = {}; -function drawPuck(ctx:CanvasRenderingContext2D, color:colorString, - puckWidth:number, puckHeight:number, yoffset = 0) { - const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; +function drawPuck( + ctx: CanvasRenderingContext2D, + color: colorString, + puckWidth: number, + puckHeight: number, + yoffset = 0, +) { + const key = "puck" + color + "_" + puckWidth + "_" + puckHeight; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = puckWidth; - can.height = puckHeight * 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = puckWidth; + can.height = puckHeight * 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; - canctx.beginPath(); - canctx.moveTo(0, puckHeight * 2); - canctx.lineTo(0, puckHeight * 1.25); - canctx.bezierCurveTo( - 0, - puckHeight * 0.75, - puckWidth, - puckHeight * 0.75, - puckWidth, - puckHeight * 1.25, - ); - canctx.lineTo(puckWidth, puckHeight * 2); - canctx.fill(); - cachedGraphics[key] = can; - } - - ctx.drawImage( - cachedGraphics[key], - Math.round(puck - puckWidth / 2), - gameZoneHeight - puckHeight * 2 + yoffset, + canctx.beginPath(); + canctx.moveTo(0, puckHeight * 2); + canctx.lineTo(0, puckHeight * 1.25); + canctx.bezierCurveTo( + 0, + puckHeight * 0.75, + puckWidth, + puckHeight * 0.75, + puckWidth, + puckHeight * 1.25, ); + canctx.lineTo(puckWidth, puckHeight * 2); + canctx.fill(); + cachedGraphics[key] = can; + } + + ctx.drawImage( + cachedGraphics[key], + Math.round(puck - puckWidth / 2), + gameZoneHeight - puckHeight * 2 + yoffset, + ); } -function drawBall(ctx:CanvasRenderingContext2D, - color:colorString, width:number, x:number, y:number, borderColor = "") { - const key = "ball" + color + "_" + width + "_" + borderColor; +function drawBall( + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, + borderColor = "", +) { + const key = "ball" + color + "_" + width + "_" + borderColor; - const size = Math.round(width); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + const size = Math.round(width); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.beginPath(); - canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); - if (borderColor) { - canctx.lineWidth = 2; - canctx.strokeStyle = borderColor; - canctx.stroke(); - } - - cachedGraphics[key] = can; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.beginPath(); + canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); + if (borderColor) { + canctx.lineWidth = 2; + canctx.strokeStyle = borderColor; + canctx.stroke(); } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } const angles = 32; -function drawCoin(ctx:CanvasRenderingContext2D, color:colorString, size:number, - x:number, y:number, borderColor:colorString, rawAngle:number) { - const angle = - ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % - angles; - const key = - "coin with halo" + - "_" + - color + - "_" + - size + - "_" + - borderColor + - "_" + - (color === "gold" ? angle : "whatever"); +function drawCoin( + ctx: CanvasRenderingContext2D, + color: colorString, + size: number, + x: number, + y: number, + borderColor: colorString, + rawAngle: number, +) { + const angle = + ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % + angles; + const key = + "coin with halo" + + "_" + + color + + "_" + + size + + "_" + + borderColor + + "_" + + (color === "gold" ? angle : "whatever"); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - // coin - canctx.beginPath(); - canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); + // coin + canctx.beginPath(); + canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); - if (color === "gold") { - canctx.strokeStyle = borderColor; - canctx.stroke(); + if (color === "gold") { + canctx.strokeStyle = borderColor; + canctx.stroke(); - canctx.beginPath(); - canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI); - canctx.fillStyle = "rgba(255,255,255,0.5)"; - canctx.fill(); + canctx.beginPath(); + canctx.arc(size / 2, size / 2, (size / 2) * 0.6, 0, 2 * Math.PI); + canctx.fillStyle = "rgba(255,255,255,0.5)"; + canctx.fill(); - canctx.translate(size / 2, size / 2); - canctx.rotate(angle / 16); - canctx.translate(-size / 2, -size / 2); + canctx.translate(size / 2, size / 2); + canctx.rotate(angle / 16); + canctx.translate(-size / 2, -size / 2); - canctx.globalCompositeOperation = "multiply"; - drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); - drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); - } - cachedGraphics[key] = can; + canctx.globalCompositeOperation = "multiply"; + drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); + drawText(canctx, "$", color, size - 2, size / 2, size / 2 + 1); } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); +} + +function drawFuzzyBall( + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, +) { + const key = "fuzzy-circle" + color + "_" + width; + if (!color) debugger; + const size = Math.round(width * 3); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; + + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const gradient = canctx.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2, ); + gradient.addColorStop(0, color); + gradient.addColorStop(1, "transparent"); + canctx.fillStyle = gradient; + canctx.fillRect(0, 0, size, size); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } -function drawFuzzyBall(ctx:CanvasRenderingContext2D, color:colorString, width:number, - x:number, y:number) { - const key = "fuzzy-circle" + color + "_" + width; - if (!color) debugger; - const size = Math.round(width * 3); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; +function drawBrick( + ctx: CanvasRenderingContext2D, + color: colorString, + borderColor: colorString, + x: number, + y: number, +) { + const tlx = Math.ceil(x - brickWidth / 2); + const tly = Math.ceil(y - brickWidth / 2); + const brx = Math.ceil(x + brickWidth / 2) - 1; + const bry = Math.ceil(y + brickWidth / 2) - 1; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - const gradient = canctx.createRadialGradient( - size / 2, - size / 2, - 0, - size / 2, - size / 2, - size / 2, - ); - gradient.addColorStop(0, color); - gradient.addColorStop(1, "transparent"); - canctx.fillStyle = gradient; - canctx.fillRect(0, 0, size, size); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), + const width = brx - tlx, + height = bry - tly; + const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; + + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = width; + can.height = height; + const bord = 2; + const cornerRadius = 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + + canctx.fillStyle = color; + canctx.strokeStyle = borderColor; + canctx.lineJoin = "round"; + canctx.lineWidth = bord; + roundRect( + canctx, + bord / 2, + bord / 2, + width - bord, + height - bord, + cornerRadius, ); + canctx.fill(); + canctx.stroke(); + + cachedGraphics[key] = can; + } + ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); + // It's not easy to have a 1px gap between bricks without antialiasing } -function drawBrick(ctx:CanvasRenderingContext2D, color:colorString, borderColor:colorString, - x:number, y:number) { - const tlx = Math.ceil(x - brickWidth / 2); - const tly = Math.ceil(y - brickWidth / 2); - const brx = Math.ceil(x + brickWidth / 2) - 1; - const bry = Math.ceil(y + brickWidth / 2) - 1; - - const width = brx - tlx, - height = bry - tly; - const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; - - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = width; - can.height = height; - const bord = 2; - const cornerRadius = 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - - canctx.fillStyle = color; - canctx.strokeStyle = borderColor; - canctx.lineJoin = "round"; - canctx.lineWidth = bord; - roundRect( - canctx, - bord / 2, - bord / 2, - width - bord, - height - bord, - cornerRadius, - ); - canctx.fill(); - canctx.stroke(); - - cachedGraphics[key] = can; - } - ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); - // It's not easy to have a 1px gap between bricks without antialiasing +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, +) { + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); } -function roundRect(ctx:CanvasRenderingContext2D, x:number, y:number, width:number, height:number, radius:number) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); +function drawRedSquare( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, +) { + ctx.fillStyle = "red"; + ctx.fillRect(x, y, width, height); } -function drawRedSquare(ctx:CanvasRenderingContext2D, x:number, y:number, width:number, height:number) { - ctx.fillStyle = "red"; - ctx.fillRect(x, y, width, height); +function drawIMG( + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + size: number, + x: number, + y: number, +) { + const key = "svg" + img + "_" + size + "_" + img.complete; + + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; + + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + + const ratio = size / Math.max(img.width, img.height); + const w = img.width * ratio; + const h = img.height * ratio; + canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); + + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } -function drawIMG(ctx:CanvasRenderingContext2D, img:HTMLImageElement, size:number, x:number, y:number) { - const key = "svg" + img + "_" + size + "_" + img.complete; +function drawText( + ctx: CanvasRenderingContext2D, + text: string, + color: colorString, + fontSize: number, + x: number, + y: number, + left = false, +) { + const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = fontSize * text.length; + can.height = fontSize; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; + canctx.textAlign = left ? "left" : "center"; + canctx.textBaseline = "middle"; + canctx.font = fontSize + "px monospace"; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); - const ratio = size / Math.max(img.width, img.height); - const w = img.width * ratio; - const h = img.height * ratio; - canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); - - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + left ? x : Math.round(x - cachedGraphics[key].width / 2), + Math.round(y - cachedGraphics[key].height / 2), + ); } -function drawText(ctx:CanvasRenderingContext2D, - text:string, color:colorString, fontSize:number, x:number, y:number, left = false) { - const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; - - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = fontSize * text.length; - can.height = fontSize; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; - canctx.textAlign = left ? "left" : "center"; - canctx.textBaseline = "middle"; - canctx.font = fontSize + "px monospace"; - - canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); - - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - left ? x : Math.round(x - cachedGraphics[key].width / 2), - Math.round(y - cachedGraphics[key].height / 2), - ); -} - -function pixelsToPan(pan:number) { - return (pan - offsetX) / gameZoneWidth; +function pixelsToPan(pan: number) { + return (pan - offsetX) / gameZoneWidth; } let lastComboPlayed = NaN, - shepard = 6; + shepard = 6; -function playShepard(delta:number, pan:number, volume:number) { - const shepardMax = 11, - factor = 1.05945594920268, - baseNote = 392; - shepard += delta; - if (shepard > shepardMax) shepard = 0; - if (shepard < 0) shepard = shepardMax; +function playShepard(delta: number, pan: number, volume: number) { + const shepardMax = 11, + factor = 1.05945594920268, + baseNote = 392; + shepard += delta; + if (shepard > shepardMax) shepard = 0; + if (shepard < 0) shepard = shepardMax; - const play = (note:number) => { - const freq = baseNote * Math.pow(factor, note); - const diff = Math.abs(note - shepardMax * 0.5); - const maxDistanceToIdeal = 1.5 * shepardMax; - const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal)); - createSingleBounceSound(freq, pan, vol); - return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff; - }; + const play = (note: number) => { + const freq = baseNote * Math.pow(factor, note); + const diff = Math.abs(note - shepardMax * 0.5); + const maxDistanceToIdeal = 1.5 * shepardMax; + const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal)); + createSingleBounceSound(freq, pan, vol); + return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff; + }; - play(1 + shepardMax + shepard); - play(shepard); - play(-1 - shepardMax + shepard); + play(1 + shepardMax + shepard); + play(shepard); + play(-1 - shepardMax + shepard); } const sounds = { - wallBeep: (pan:number) => { - if (!isSettingOn("sound")) return; - createSingleBounceSound(800, pixelsToPan(pan)); - }, + wallBeep: (pan: number) => { + if (!isSettingOn("sound")) return; + createSingleBounceSound(800, pixelsToPan(pan)); + }, - comboIncreaseMaybe: (x:number, volume:number) => { - if (!isSettingOn("sound")) return; - let delta = 0; - if (!isNaN(lastComboPlayed)) { - if (lastComboPlayed < combo) delta = 1; - if (lastComboPlayed > combo) delta = -1; - } - playShepard(delta, pixelsToPan(x), volume); - lastComboPlayed = combo; - }, + comboIncreaseMaybe: (x: number, volume: number) => { + if (!isSettingOn("sound")) return; + let delta = 0; + if (!isNaN(lastComboPlayed)) { + if (lastComboPlayed < combo) delta = 1; + if (lastComboPlayed > combo) delta = -1; + } + playShepard(delta, pixelsToPan(x), volume); + lastComboPlayed = combo; + }, - comboDecrease() { - if (!isSettingOn("sound")) return; - playShepard(-1, 0.5, 0.5); - }, - coinBounce: (pan:number, volume:number) => { - if (!isSettingOn("sound")) return; - createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle"); - }, - explode: (pan:number) => { - if (!isSettingOn("sound")) return; - createExplosionSound(pixelsToPan(pan)); - }, - revive: () => { - if (!isSettingOn("sound")) return; - createRevivalSound(500); - }, - coinCatch(pan:number) { - if (!isSettingOn("sound")) return; - createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle"); - }, + comboDecrease() { + if (!isSettingOn("sound")) return; + playShepard(-1, 0.5, 0.5); + }, + coinBounce: (pan: number, volume: number) => { + if (!isSettingOn("sound")) return; + createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle"); + }, + explode: (pan: number) => { + if (!isSettingOn("sound")) return; + createExplosionSound(pixelsToPan(pan)); + }, + revive: () => { + if (!isSettingOn("sound")) return; + createRevivalSound(500); + }, + coinCatch(pan: number) { + if (!isSettingOn("sound")) return; + createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle"); + }, }; // How to play the code on the leftconst context = new window.AudioContext(); -let audioContext:AudioContext, audioRecordingTrack:MediaStreamAudioDestinationNode; +let audioContext: AudioContext, + audioRecordingTrack: MediaStreamAudioDestinationNode; function getAudioContext() { - if (!audioContext) { - audioContext = new (window.AudioContext || window.webkitAudioContext)(); - audioRecordingTrack = audioContext.createMediaStreamDestination(); - } - return audioContext; + if (!audioContext) { + audioContext = new (window.AudioContext || window.webkitAudioContext)(); + audioRecordingTrack = audioContext.createMediaStreamDestination(); + } + return audioContext; } function createSingleBounceSound( - baseFreq = 800, - pan = 0.5, - volume = 1, - duration = 0.1, - type:OscillatorType = "sine", + baseFreq = 800, + pan = 0.5, + volume = 1, + duration = 0.1, + type: OscillatorType = "sine", ) { - const context = getAudioContext(); - // Frequency for the metal "ping" - const baseFrequency = baseFreq; // Hz + const context = getAudioContext(); + // Frequency for the metal "ping" + const baseFrequency = baseFreq; // Hz - // Create an oscillator for the impact sound - const oscillator = context.createOscillator(); - oscillator.type = type; - oscillator.frequency.setValueAtTime(baseFrequency, context.currentTime); + // Create an oscillator for the impact sound + const oscillator = context.createOscillator(); + oscillator.type = type; + oscillator.frequency.setValueAtTime(baseFrequency, context.currentTime); - // Create a gain node to control the volume - const gainNode = context.createGain(); - oscillator.connect(gainNode); + // Create a gain node to control the volume + const gainNode = context.createGain(); + oscillator.connect(gainNode); - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); - gainNode.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); + gainNode.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Set up the gain envelope to simulate the impact and quick decay - gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact - gainNode.gain.exponentialRampToValueAtTime( - 0.001, - context.currentTime + duration, - ); // Quick decay + // Set up the gain envelope to simulate the impact and quick decay + gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact + gainNode.gain.exponentialRampToValueAtTime( + 0.001, + context.currentTime + duration, + ); // Quick decay - // Start the oscillator - oscillator.start(context.currentTime); + // Start the oscillator + oscillator.start(context.currentTime); - // Stop the oscillator after the decay - oscillator.stop(context.currentTime + duration); + // Stop the oscillator after the decay + oscillator.stop(context.currentTime + duration); } function createRevivalSound(baseFreq = 440) { - const context = getAudioContext(); + const context = getAudioContext(); - // Create multiple oscillators for a richer sound - const oscillators = [ - context.createOscillator(), - context.createOscillator(), - context.createOscillator(), - ]; + // Create multiple oscillators for a richer sound + const oscillators = [ + context.createOscillator(), + context.createOscillator(), + context.createOscillator(), + ]; - // Set the type and frequency for each oscillator - oscillators.forEach((osc, index) => { - osc.type = "sine"; - osc.frequency.setValueAtTime(baseFreq + index * 2, context.currentTime); // Slight detuning - }); + // Set the type and frequency for each oscillator + oscillators.forEach((osc, index) => { + osc.type = "sine"; + osc.frequency.setValueAtTime(baseFreq + index * 2, context.currentTime); // Slight detuning + }); - // Create a gain node to control the volume - const gainNode = context.createGain(); + // Create a gain node to control the volume + const gainNode = context.createGain(); - // Connect all oscillators to the gain node - oscillators.forEach((osc) => osc.connect(gainNode)); + // Connect all oscillators to the gain node + oscillators.forEach((osc) => osc.connect(gainNode)); - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(0, context.currentTime); // Center panning - gainNode.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(0, context.currentTime); // Center panning + gainNode.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Set up the gain envelope to simulate a smooth attack and decay - gainNode.gain.setValueAtTime(0, context.currentTime); // Start at zero - gainNode.gain.linearRampToValueAtTime(0.5, context.currentTime + 0.5); // Ramp up to full volume - gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 2); // Slow decay + // Set up the gain envelope to simulate a smooth attack and decay + gainNode.gain.setValueAtTime(0, context.currentTime); // Start at zero + gainNode.gain.linearRampToValueAtTime(0.5, context.currentTime + 0.5); // Ramp up to full volume + gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 2); // Slow decay - // Start all oscillators - oscillators.forEach((osc) => osc.start(context.currentTime)); + // Start all oscillators + oscillators.forEach((osc) => osc.start(context.currentTime)); - // Stop all oscillators after the decay - oscillators.forEach((osc) => osc.stop(context.currentTime + 2)); + // Stop all oscillators after the decay + oscillators.forEach((osc) => osc.stop(context.currentTime + 2)); } -let noiseBuffer:AudioBuffer; +let noiseBuffer: AudioBuffer; function createExplosionSound(pan = 0.5) { - const context = getAudioContext(); - // Create an audio buffer - if (!noiseBuffer) { - const bufferSize = context.sampleRate * 2; // 2 seconds - noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate); - const output = noiseBuffer.getChannelData(0); + const context = getAudioContext(); + // Create an audio buffer + if (!noiseBuffer) { + const bufferSize = context.sampleRate * 2; // 2 seconds + noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate); + const output = noiseBuffer.getChannelData(0); - // Fill the buffer with random noise - for (let i = 0; i < bufferSize; i++) { - output[i] = Math.random() * 2 - 1; - } + // Fill the buffer with random noise + for (let i = 0; i < bufferSize; i++) { + output[i] = Math.random() * 2 - 1; } + } - // Create a noise source - const noiseSource = context.createBufferSource(); - noiseSource.buffer = noiseBuffer; + // Create a noise source + const noiseSource = context.createBufferSource(); + noiseSource.buffer = noiseBuffer; - // Create a gain node to control the volume - const gainNode = context.createGain(); - noiseSource.connect(gainNode); + // Create a gain node to control the volume + const gainNode = context.createGain(); + noiseSource.connect(gainNode); - // Create a filter to shape the explosion sound - const filter = context.createBiquadFilter(); - filter.type = "lowpass"; - filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency - gainNode.connect(filter); + // Create a filter to shape the explosion sound + const filter = context.createBiquadFilter(); + filter.type = "lowpass"; + filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency + gainNode.connect(filter); - // Create a stereo panner node for left-right panning - const panner = context.createStereoPanner(); - panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1 + // Create a stereo panner node for left-right panning + const panner = context.createStereoPanner(); + panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1 - // Connect filter to panner and then to the destination (speakers) - filter.connect(panner); - panner.connect(context.destination); - panner.connect(audioRecordingTrack); + // Connect filter to panner and then to the destination (speakers) + filter.connect(panner); + panner.connect(context.destination); + panner.connect(audioRecordingTrack); - // Ramp down the gain to simulate the explosion's fade-out - gainNode.gain.setValueAtTime(1, context.currentTime); - gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1); + // Ramp down the gain to simulate the explosion's fade-out + gainNode.gain.setValueAtTime(1, context.currentTime); + gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1); - // Lower the filter frequency over time to create the "explosive" effect - filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1); + // Lower the filter frequency over time to create the "explosive" effect + filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1); - // Start the noise source - noiseSource.start(context.currentTime); + // Start the noise source + noiseSource.start(context.currentTime); - // Stop the noise source after the sound has played - noiseSource.stop(context.currentTime + 1); + // Stop the noise source after the sound has played + noiseSource.stop(context.currentTime + 1); } let levelTime = 0; - +// Limits skip last to one use per level +let level_skip_last_uses = 0; window.addEventListener("visibilitychange", () => { - if (document.hidden) { - pause(true); - } + if (document.hidden) { + pause(true); + } }); const scoreDisplay = document.getElementById("score"); let alertsOpen = 0, - closeModal = null; + closeModal = null; function asyncAlert({ - title, - text, - actions, - allowClose = true, - textAfterButtons = "", - }: { - title?: string; + title, + text, + actions, + allowClose = true, + textAfterButtons = "", + actionsAsGrid = false, +}: { + title?: string; + text?: string; + actions?: { text?: string; - actions?: { - text?: string; - value?: t; - help?: string; - disabled?: boolean; - icon?: string; - }[]; - textAfterButtons?: string; - allowClose?: boolean; + value?: t; + help?: string; + disabled?: boolean; + icon?: string; + className?: string; + }[]; + textAfterButtons?: string; + allowClose?: boolean; + actionsAsGrid?: boolean; }): Promise { - alertsOpen++; - return new Promise((resolve) => { - const popupWrap = document.createElement("div"); - document.body.appendChild(popupWrap); - popupWrap.className = "popup"; + alertsOpen++; + return new Promise((resolve) => { + const popupWrap = document.createElement("div"); + document.body.appendChild(popupWrap); + popupWrap.className = "popup " + (actionsAsGrid ? "actionsAsGrid " : ""); - function closeWithResult(value: t | void) { - resolve(value); - // Doing this async lets the menu scroll persist if it's shown a second time - setTimeout(() => { - document.body.removeChild(popupWrap); - }); - } + function closeWithResult(value: t | void) { + resolve(value); + // Doing this async lets the menu scroll persist if it's shown a second time + setTimeout(() => { + document.body.removeChild(popupWrap); + }); + } - if (allowClose) { - const closeButton = document.createElement("button"); - closeButton.title = "close"; - closeButton.className = "close-modale"; - closeButton.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(null); - }); - closeModal = () => { - closeWithResult(null); - }; - popupWrap.appendChild(closeButton); - } + if (allowClose) { + const closeButton = document.createElement("button"); + closeButton.title = "close"; + closeButton.className = "close-modale"; + closeButton.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(null); + }); + closeModal = () => { + closeWithResult(null); + }; + popupWrap.appendChild(closeButton); + } - const popup = document.createElement("div"); + const popup = document.createElement("div"); - if (title) { - const p = document.createElement("h2"); - p.innerHTML = title; - popup.appendChild(p); - } + if (title) { + const p = document.createElement("h2"); + p.innerHTML = title; + popup.appendChild(p); + } - if (text) { - const p = document.createElement("div"); - p.innerHTML = text; - popup.appendChild(p); - } + if (text) { + const p = document.createElement("div"); + p.innerHTML = text; + popup.appendChild(p); + } - actions - .filter((i) => i) - .forEach(({text, value, help, disabled, icon = ""}) => { - const button = document.createElement("button"); + const buttons = document.createElement("section"); + popup.appendChild(buttons); - button.innerHTML = ` + actions + .filter((i) => i) + .forEach(({ text, value, help, disabled, className = "", icon = "" }) => { + const button = document.createElement("button"); + + button.innerHTML = ` ${icon}
${text} ${help || ""}
`; - if (disabled) { - button.setAttribute("disabled", "disabled"); - } else { - button.addEventListener("click", (e) => { - e.preventDefault(); - closeWithResult(value); - }); - } - popup.appendChild(button); - }); - - if (textAfterButtons) { - const p = document.createElement("div"); - p.className = "textAfterButtons"; - p.innerHTML = textAfterButtons; - popup.appendChild(p); + if (disabled) { + button.setAttribute("disabled", "disabled"); + } else { + button.addEventListener("click", (e) => { + e.preventDefault(); + closeWithResult(value); + }); } + button.className = className; + buttons.appendChild(button); + }); - popupWrap.appendChild(popup); - ( - popup.querySelector("button:not([disabled])") as HTMLButtonElement - )?.focus(); - }).then( - (v: t | null) => { - alertsOpen--; - closeModal = null; - return v; - }, - () => { - closeModal = null; - alertsOpen--; - }, - ); + if (textAfterButtons) { + const p = document.createElement("div"); + p.className = "textAfterButtons"; + p.innerHTML = textAfterButtons; + popup.appendChild(p); + } + + popupWrap.appendChild(popup); + ( + popup.querySelector("button:not([disabled])") as HTMLButtonElement + )?.focus(); + }).then( + (v: t | null) => { + alertsOpen--; + closeModal = null; + return v; + }, + () => { + closeModal = null; + alertsOpen--; + }, + ); } // Settings let cachedSettings = {}; export function isSettingOn(key: OptionId) { - if (typeof cachedSettings[key] == "undefined") { - try { - cachedSettings[key] = JSON.parse( - localStorage.getItem("breakout-settings-enable-" + key), - ); - } catch (e) { - console.warn(e); - } - } - return cachedSettings[key] ?? options[key]?.default ?? false; -} - -export function toggleSetting(key:OptionId) { - cachedSettings[key] = !isSettingOn(key); + if (typeof cachedSettings[key] == "undefined") { try { - const lskey = "breakout-settings-enable-" + key; - localStorage.setItem(lskey, JSON.stringify(cachedSettings[key])); + cachedSettings[key] = JSON.parse( + localStorage.getItem("breakout-settings-enable-" + key), + ); } catch (e) { - console.warn(e); + console.warn(e); } - if (options[key].afterChange) options[key].afterChange(); + } + return cachedSettings[key] ?? options[key]?.default ?? false; } +export function toggleSetting(key: OptionId) { + cachedSettings[key] = !isSettingOn(key); + try { + const lskey = "breakout-settings-enable-" + key; + localStorage.setItem(lskey, JSON.stringify(cachedSettings[key])); + } catch (e) { + console.warn(e); + } + if (options[key].afterChange) options[key].afterChange(); +} scoreDisplay.addEventListener("click", (e) => { - e.preventDefault(); - openScorePanel().then(); + e.preventDefault(); + openScorePanel().then(); }); async function openScorePanel() { - pause(true); - const cb = await asyncAlert({ - title: ` ${score} points at level ${currentLevel + 1} / ${max_levels()}`, - text: ` + pause(true); + const cb = await asyncAlert({ + title: ` ${score} points at level ${currentLevel + 1} / ${max_levels()}`, + text: ` + ${ignoreThisRunInStats ? "

This is a test run, score is not recorded permanently

" : ""}

Upgrades picked so far :

${pickedUpgradesHTMl()}

`, - allowClose: true, - actions: [ - { - text: "Resume", - help: "Return to your run", - value:()=>{} - }, - { - text: "Restart", - help: "Start a brand new run.", - value: () => { - restart(); - }, - }, - ], - }); - if (cb) { - cb(); - } + allowClose: true, + actions: [ + { + text: "Resume", + help: "Return to your run", + value: () => {}, + }, + { + text: "Restart", + help: "Start a brand new run.", + value: () => { + restart(); + }, + }, + ], + }); + if (cb) { + cb(); + } } document.getElementById("menu").addEventListener("click", (e) => { - e.preventDefault(); - openSettingsPanel().then(); + e.preventDefault(); + openSettingsPanel().then(); }); - async function openSettingsPanel() { - pause(true); + pause(true); - const optionsList = []; - for (const key in options) { - if (options[key]) - optionsList.push({ - disabled: options[key].disabled(), - icon: isSettingOn(key) - ? icons["icon:checkmark_checked"] - : icons["icon:checkmark_unchecked"], - text: options[key].name, - help: options[key].help, - value: () => { - toggleSetting(key); - openSettingsPanel(); - }, - }); - } + const optionsList = []; + for (const key in options) { + if (options[key]) + optionsList.push({ + disabled: options[key].disabled(), + icon: isSettingOn(key) + ? icons["icon:checkmark_checked"] + : icons["icon:checkmark_unchecked"], + text: options[key].name, + help: options[key].help, + value: () => { + toggleSetting(key); + openSettingsPanel(); + }, + }); + } + const creativeModeTreshold=Math.max(...upgrades.map((u) => u.threshold)) - const cb = await asyncAlert<() => void>({ - title: "Breakout 71", - text: ` + const cb = await asyncAlert<() => void>({ + title: "Breakout 71", + text: ` `, - allowClose: true, - actions: [ - { - text: "Resume", - help: "Return to your run", - value() { - }, - }, - { - text: "Starting perk", - help: "Try perks and levels you unlocked", - value() { - openUnlocksList() - }, - }, + allowClose: true, + actions: [ + { + text: "Resume", + help: "Return to your run", + value() {}, + }, + { + text: "Starting perk", + help: "Try perks and levels you unlocked", + value() { + openUnlocksList(); + }, + }, + ...optionsList, - ...optionsList, + (document.fullscreenEnabled || document.webkitFullscreenEnabled) && + (document.fullscreenElement !== null + ? { + text: "Exit Fullscreen", + icon: icons["icon:exit_fullscreen"], + help: "Might not work on some machines", + value() { + toggleFullScreen(); + }, + } + : { + icon: icons["icon:fullscreen"], + text: "Fullscreen", + help: "Might not work on some machines", + value() { + toggleFullScreen(); + }, + }), - (document.fullscreenEnabled || document.webkitFullscreenEnabled) && - (document.fullscreenElement !== null - ? { - text: "Exit Fullscreen", - icon: icons["icon:exit_fullscreen"], - help: "Might not work on some machines", - value() { - toggleFullScreen(); - }, - } - : { - icon: icons["icon:fullscreen"], - text: "Fullscreen", - help: "Might not work on some machines", - value() { - toggleFullScreen(); - }, - }), - { - text: "Reset Game", - help: "Erase high score and statistics", - async value() { - if ( - await asyncAlert({ - title: "Reset", - actions: [ - { - text: "Yes", - value: true, - }, - { - text: "No", - value: false, - }, - ], - allowClose: true, - }) - ) { - localStorage.clear(); - window.location.reload(); - } + + { + text: "Creative mode", + help:getTotalScore() < creativeModeTreshold ? "Unlocks at total score $"+creativeModeTreshold: "Test runs with custom perks" , + disabled: getTotalScore() < creativeModeTreshold, + async value() { + let creativeModePerks = {}, + choice; + while ( + (choice = await asyncAlert({ + title: "Select perks", + text: 'Select perks below and press "start run" to try them out in a test run. Scores and stats are not recorded.', + actionsAsGrid: true, + actions: [ + ...upgrades.map((u) => ({ + icon: u.icon, + text: u.name, + help: (creativeModePerks[u.id] || 0) + "/" + u.max, + value: u, + className: creativeModePerks[u.id] + ? "" + : "grey-out-unless-hovered", + })), + { + text: "Start run", + value: "start", }, - }, - ], - textAfterButtons: ` + ], + })) + ) { + if (choice === "start") { + restart(); + ignoreThisRunInStats = true; + Object.assign(perks, creativeModePerks); + break; + } else if (choice) { + creativeModePerks[choice.id] = + ((creativeModePerks[choice.id] || 0) + 1) % (choice.max + 1); + } + } + }, + }, + + { + text: "Reset Game", + help: "Erase high score and statistics", + async value() { + if ( + await asyncAlert({ + title: "Reset", + actions: [ + { + text: "Yes", + value: true, + }, + { + text: "No", + value: false, + }, + ], + allowClose: true, + }) + ) { + localStorage.clear(); + window.location.reload(); + } + }, + }, + ], + textAfterButtons: `

Made in France by Renan LE CARO. Privacy Policy @@ -2701,433 +2871,448 @@ async function openSettingsPanel() { v.${appVersion}

`, - }); - if (cb) { - cb(); - } + }); + if (cb) { + cb(); + } } async function openUnlocksList() { + const ts = getTotalScore(); + const actions = [ + ...upgrades + .sort((a, b) => a.threshold - b.threshold) + .map(({ name, id, threshold, icon, fullHelp }) => ({ + text: name, + help: + ts >= threshold ? fullHelp : `Unlocks at total score ${threshold}.`, + disabled: ts < threshold, + value: { perk: id } as RunOverrides, + icon, + })), + ...allLevels + .sort((a, b) => a.threshold - b.threshold) + .map((l) => { + const available = ts >= l.threshold; + return { + text: l.name, + help: available + ? `A ${l.size}x${l.size} level with ${l.bricks.filter((i) => i).length} bricks` + : `Unlocks at total score ${l.threshold}.`, + disabled: !available, + value: { level: l.name } as RunOverrides, + icon: icons[l.name], + }; + }), + ]; - const ts = getTotalScore(); - const actions = [ - ...upgrades - .sort((a, b) => a.threshold - b.threshold) - .map(({name, id, threshold, icon, fullHelp}) => ({ - text: name, - help: - ts >= threshold - ? fullHelp - : `Unlocks at total score ${threshold}.`, - disabled: ts < threshold, - value: {perk: id} as RunOverrides, - icon, - })), - ...allLevels - .sort((a, b) => a.threshold - b.threshold) - .map((l) => { - const available = ts >= l.threshold; - return { - text: l.name, - help: available - ? `A ${l.size}x${l.size} level with ${l.bricks.filter((i) => i).length} bricks` - : `Unlocks at total score ${l.threshold}.`, - disabled: !available, - value: {level: l.name} as RunOverrides, - icon: icons[l.name], - }; - }), - ]; - - const tryOn = await asyncAlert({ - title: `You unlocked ${Math.round((actions.filter((a) => !a.disabled).length / actions.length) * 100)}% of the game.`, - text: ` + const tryOn = await asyncAlert({ + title: `You unlocked ${Math.round((actions.filter((a) => !a.disabled).length / actions.length) * 100)}% of the game.`, + text: `

Your total score is ${ts}. Below are all the upgrades and levels the games has to offer. They greyed out ones can be unlocked by increasing your total score.

`, - textAfterButtons: `

+ textAfterButtons: `

The total score increases every time you score in game. Your high score is ${highScore}. Click an item above to start a run with it.

`, - actions, - allowClose: true, - }); - if (tryOn) { - if ( - !currentLevel || - (await asyncAlert({ - title: "Restart run to try this item?", - text: "You're about to start a new run with the selected unlocked item, is that really what you wanted ? ", - actions: [ - { - value: true, - text: "Restart game to test item", - }, - { - value: false, - text: "Cancel", - }, - ], - })) - ) { - nextRunOverrides = tryOn; - restart(); - } + actions, + allowClose: true, + }); + if (tryOn) { + if ( + !currentLevel || + (await asyncAlert({ + title: "Restart run to try this item?", + text: "You're about to start a new run with the selected unlocked item, is that really what you wanted ? ", + actions: [ + { + value: true, + text: "Restart game to test item", + }, + { + value: false, + text: "Cancel", + }, + ], + })) + ) { + nextRunOverrides = tryOn; + restart(); } + } } -function distance2(a:{x:number,y:number}, b:{x:number,y:number}) { - return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); +function distance2(a: { x: number; y: number }, b: { x: number; y: number }) { + return Math.pow(a.x - b.x, 2) + Math.pow(a.y - b.y, 2); } -function distanceBetween(a:{x:number,y:number}, b:{x:number,y:number}) { - return Math.sqrt(distance2(a, b)); +function distanceBetween( + a: { x: number; y: number }, + b: { x: number; y: number }, +) { + return Math.sqrt(distance2(a, b)); } -function rainbowColor():colorString { - return `hsl(${(Math.round(levelTime / 4) * 2) % 360},100%,70%)`; +function rainbowColor(): colorString { + return `hsl(${(Math.round(levelTime / 4) * 2) % 360},100%,70%)`; } -function repulse(a:Ball, b:BallLike, power:number, impactsBToo:boolean) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameZoneWidth / 2; - if (distance > max) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; - const fact = - (((-power * (max - distance)) / (max * 1.2) / 3) * - Math.min(500, levelTime)) / - 500; - if (impactsBToo) { - b.vx += dx * fact; - b.vy += dy * fact; - } - a.vx -= dx * fact; - a.vy -= dy * fact; - - const speed = 10; - const rand = 2; - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, - }); - if (impactsBToo) { - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, - }); - } -} - -function attract(a:Ball, b:BallLike, power:number) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = gameZoneWidth * 0.5; - if (distance < min) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; - - const fact = - (((power * (distance - min)) / min) * Math.min(500, levelTime)) / 500; +function repulse(a: Ball, b: BallLike, power: number, impactsBToo: boolean) { + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const max = gameZoneWidth / 2; + if (distance > max) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; + const fact = + (((-power * (max - distance)) / (max * 1.2) / 3) * + Math.min(500, levelTime)) / + 500; + if (impactsBToo) { b.vx += dx * fact; b.vy += dy * fact; - a.vx -= dx * fact; - a.vy -= dy * fact; + } + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: -dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + if (impactsBToo) { flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: a.x, - y: a.y, - vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, - vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, - }); - flashes.push({ - type: "particle", - duration: 100, - time: levelTime, - size: coinSize / 2, - color: rainbowColor(), - ethereal: true, - x: b.x, - y: b.y, - vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, - vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + b.vy + (Math.random() - 0.5) * rand, }); + } } -let mediaRecorder:MediaRecorder, - captureStream:MediaStream, - captureTrack:CanvasCaptureMediaStreamTrack, - recordCanvas:HTMLCanvasElement, - recordCanvasCtx:CanvasRenderingContext2D; +function attract(a: Ball, b: BallLike, power: number) { + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const min = gameZoneWidth * 0.5; + if (distance < min) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; + + const fact = + (((power * (distance - min)) / min) * Math.min(500, levelTime)) / 500; + b.vx += dx * fact; + b.vy += dy * fact; + a.vx -= dx * fact; + a.vy -= dy * fact; + + const speed = 10; + const rand = 2; + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: a.x, + y: a.y, + vx: dx * speed + a.vx + (Math.random() - 0.5) * rand, + vy: dy * speed + a.vy + (Math.random() - 0.5) * rand, + }); + flashes.push({ + type: "particle", + duration: 100, + time: levelTime, + size: coinSize / 2, + color: rainbowColor(), + ethereal: true, + x: b.x, + y: b.y, + vx: -dx * speed + b.vx + (Math.random() - 0.5) * rand, + vy: -dy * speed + b.vy + (Math.random() - 0.5) * rand, + }); +} + +let mediaRecorder: MediaRecorder, + captureStream: MediaStream, + captureTrack: CanvasCaptureMediaStreamTrack, + recordCanvas: HTMLCanvasElement, + recordCanvasCtx: CanvasRenderingContext2D; function recordOneFrame() { - if (!isSettingOn("record")) { - return; - } - if (!running) return; - if (!captureStream) return; - drawMainCanvasOnSmallCanvas(); - if (captureTrack?.requestFrame) { - captureTrack?.requestFrame(); - }else if(captureStream?.requestFrame){ - captureStream.requestFrame(); - - } + if (!isSettingOn("record")) { + return; + } + if (!running) return; + if (!captureStream) return; + drawMainCanvasOnSmallCanvas(); + if (captureTrack?.requestFrame) { + captureTrack?.requestFrame(); + } else if (captureStream?.requestFrame) { + captureStream.requestFrame(); + } } function drawMainCanvasOnSmallCanvas() { - if (!recordCanvasCtx) return; - recordCanvasCtx.drawImage( - gameCanvas, - offsetXRoundedDown, - 0, - gameZoneWidthRoundedUp, - gameZoneHeight, - 0, - 0, - recordCanvas.width, - recordCanvas.height, - ); + if (!recordCanvasCtx) return; + recordCanvasCtx.drawImage( + gameCanvas, + offsetXRoundedDown, + 0, + gameZoneWidthRoundedUp, + gameZoneHeight, + 0, + 0, + recordCanvas.width, + recordCanvas.height, + ); - // Here we don't use drawText as we don't want to cache a picture for each distinct value of score - recordCanvasCtx.fillStyle = "#FFF"; - recordCanvasCtx.textBaseline = "top"; - recordCanvasCtx.font = "12px monospace"; - recordCanvasCtx.textAlign = "right"; - recordCanvasCtx.fillText(score.toString(), recordCanvas.width - 12, 12); + // Here we don't use drawText as we don't want to cache a picture for each distinct value of score + recordCanvasCtx.fillStyle = "#FFF"; + recordCanvasCtx.textBaseline = "top"; + recordCanvasCtx.font = "12px monospace"; + recordCanvasCtx.textAlign = "right"; + recordCanvasCtx.fillText(score.toString(), recordCanvas.width - 12, 12); - recordCanvasCtx.textAlign = "left"; - recordCanvasCtx.fillText( - "Level " + (currentLevel + 1) + "/" + max_levels(), - 12, - 12, - ); + recordCanvasCtx.textAlign = "left"; + recordCanvasCtx.fillText( + "Level " + (currentLevel + 1) + "/" + max_levels(), + 12, + 12, + ); } function startRecordingGame() { - if (!isSettingOn("record")) { - return; + if (!isSettingOn("record")) { + return; + } + if (!recordCanvas) { + // Smaller canvas with fewer details + recordCanvas = document.createElement("canvas"); + recordCanvasCtx = recordCanvas.getContext("2d", { + antialias: false, + alpha: false, + }) as CanvasRenderingContext2D; + + captureStream = recordCanvas.captureStream(0); + captureTrack = + captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack; + + if (isSettingOn("sound") && getAudioContext() && audioRecordingTrack) { + captureStream.addTrack(audioRecordingTrack.stream.getAudioTracks()[0]); } - if (!recordCanvas) { - // Smaller canvas with fewer details - recordCanvas = document.createElement("canvas"); - recordCanvasCtx = recordCanvas.getContext("2d", { - antialias: false, - alpha: false, - }) as CanvasRenderingContext2D; + } - captureStream = recordCanvas.captureStream(0) ; - captureTrack = captureStream.getVideoTracks()[0] as CanvasCaptureMediaStreamTrack + recordCanvas.width = gameZoneWidthRoundedUp; + recordCanvas.height = gameZoneHeight; - if (isSettingOn("sound") && getAudioContext() && audioRecordingTrack) { - captureStream.addTrack(audioRecordingTrack.stream.getAudioTracks()[0]); - } + // drawMainCanvasOnSmallCanvas() + const recordedChunks = []; + + const instance = new MediaRecorder(captureStream, { + videoBitsPerSecond: 3500000, + }); + mediaRecorder = instance; + instance.start(); + mediaRecorder.pause(); + instance.ondataavailable = function (event) { + recordedChunks.push(event.data); + }; + + instance.onstop = async function () { + let targetDiv: HTMLElement; + let blob = new Blob(recordedChunks, { type: "video/webm" }); + if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short + + while ( + !(targetDiv = document.getElementById("level-recording-container")) + ) { + await new Promise((r) => setTimeout(r, 200)); } + const video = document.createElement("video"); + video.autoplay = true; + video.controls = false; + video.disablePictureInPicture = true; + video.disableRemotePlayback = true; + video.width = recordCanvas.width; + video.height = recordCanvas.height; + // targetDiv.style.width = recordCanvas.width + 'px' + // targetDiv.style.height = recordCanvas.height + 'px' + video.loop = true; + video.muted = true; + video.playsInline = true; + video.src = URL.createObjectURL(blob); - recordCanvas.width = gameZoneWidthRoundedUp; - recordCanvas.height = gameZoneHeight; - - // drawMainCanvasOnSmallCanvas() - const recordedChunks = []; - - const instance = new MediaRecorder(captureStream, { - videoBitsPerSecond: 3500000, - }); - mediaRecorder = instance; - instance.start(); - mediaRecorder.pause(); - instance.ondataavailable = function (event) { - recordedChunks.push(event.data); - }; - - instance.onstop = async function () { - let targetDiv:HTMLElement; - let blob = new Blob(recordedChunks, {type: "video/webm"}); - if (blob.size < 200000) return; // under 0.2MB, probably bugged out or pointlessly short - - while ( - !(targetDiv = document.getElementById("level-recording-container")) - ) { - await new Promise((r) => setTimeout(r, 200)); - } - const video = document.createElement("video"); - video.autoplay = true; - video.controls = false; - video.disablePictureInPicture = true; - video.disableRemotePlayback = true; - video.width = recordCanvas.width; - video.height = recordCanvas.height; - // targetDiv.style.width = recordCanvas.width + 'px' - // targetDiv.style.height = recordCanvas.height + 'px' - video.loop = true; - video.muted = true; - video.playsInline = true; - video.src = URL.createObjectURL(blob); - - const a = document.createElement("a"); - a.download = captureFileName("webm"); - a.target = "_blank"; - a.href = video.src; - a.textContent = `Download video (${(blob.size / 1000000).toFixed(2)}MB)`; - targetDiv.appendChild(video); - targetDiv.appendChild(a); - }; + const a = document.createElement("a"); + a.download = captureFileName("webm"); + a.target = "_blank"; + a.href = video.src; + a.textContent = `Download video (${(blob.size / 1000000).toFixed(2)}MB)`; + targetDiv.appendChild(video); + targetDiv.appendChild(a); + }; } function pauseRecording() { - if (!isSettingOn("record")) { - return; - } - if (mediaRecorder?.state === "recording") { - mediaRecorder?.pause(); - } + if (!isSettingOn("record")) { + return; + } + if (mediaRecorder?.state === "recording") { + mediaRecorder?.pause(); + } } function resumeRecording() { - if (!isSettingOn("record")) { - return; - } - if (mediaRecorder?.state === "paused") { - mediaRecorder.resume(); - } + if (!isSettingOn("record")) { + return; + } + if (mediaRecorder?.state === "paused") { + mediaRecorder.resume(); + } } function stopRecording() { - if (!isSettingOn("record")) { - return; + if (!isSettingOn("record")) { + return; + } + if (!mediaRecorder) return; + mediaRecorder?.stop(); + mediaRecorder = null; +} + +function captureFileName(ext = "webm") { + return ( + "breakout-71-capture-" + + new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + + "." + + ext + ); +} + +function findLast( + arr: T[], + predicate: (item: T, index: number, array: T[]) => boolean, +) { + let i = arr.length; + while (--i) + if (predicate(arr[i], i, arr)) { + return arr[i]; } - if (!mediaRecorder) return; - mediaRecorder?.stop(); - mediaRecorder = null; -} - -function captureFileName(ext='webm') { - return ( - "breakout-71-capture-" + - new Date().toISOString().replace(/[^0-9\-]+/gi, "-") + - "." + - ext - ); -} - -function findLast(arr:T[], predicate:(item:T,index:number,array:T[])=>boolean) { - let i = arr.length; - while (--i) - if (predicate(arr[i], i, arr)) { - return arr[i]; - } } function toggleFullScreen() { - try { - if (document.fullscreenElement !== null) { - if (document.exitFullscreen) { - document.exitFullscreen().then(); - } else if (document.webkitCancelFullScreen) { - document.webkitCancelFullScreen(); - } - } else { - const docel = document.documentElement; - if (docel.requestFullscreen) { - docel.requestFullscreen().then(); - } else if (docel.webkitRequestFullscreen) { - docel.webkitRequestFullscreen(); - } - } - } catch (e) { - console.warn(e); + try { + if (document.fullscreenElement !== null) { + if (document.exitFullscreen) { + document.exitFullscreen().then(); + } else if (document.webkitCancelFullScreen) { + document.webkitCancelFullScreen(); + } + } else { + const docel = document.documentElement; + if (docel.requestFullscreen) { + docel.requestFullscreen().then(); + } else if (docel.webkitRequestFullscreen) { + docel.webkitRequestFullscreen(); + } } + } catch (e) { + console.warn(e); + } } const pressed = { - ArrowLeft: 0, - ArrowRight: 0, - Shift: 0, + ArrowLeft: 0, + ArrowRight: 0, + Shift: 0, }; -function setKeyPressed(key:string, on:0|1) { - pressed[key] = on; - keyboardPuckSpeed = - ((pressed.ArrowRight - pressed.ArrowLeft) * - (1 + pressed.Shift * 2) * - gameZoneWidth) / - 50; +function setKeyPressed(key: string, on: 0 | 1) { + pressed[key] = on; + keyboardPuckSpeed = + ((pressed.ArrowRight - pressed.ArrowLeft) * + (1 + pressed.Shift * 2) * + gameZoneWidth) / + 50; } document.addEventListener("keydown", (e) => { - if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) { - toggleFullScreen(); - } else if (e.key in pressed) { - setKeyPressed(e.key, 1); - } - if (e.key === " " && !alertsOpen) { - if (running) { - pause(true); - } else { - play(); - } + if (e.key.toLowerCase() === "f" && !e.ctrlKey && !e.metaKey) { + toggleFullScreen(); + } else if (e.key in pressed) { + setKeyPressed(e.key, 1); + } + if (e.key === " " && !alertsOpen) { + if (running) { + pause(true); } else { - return; + play(); } - e.preventDefault(); + } else { + return; + } + e.preventDefault(); }); document.addEventListener("keyup", (e) => { - const focused = document.querySelector("button:focus") - if (e.key in pressed) { - setKeyPressed(e.key, 0); - } else if ( - e.key === "ArrowDown" && focused?.nextElementSibling?.tagName === "BUTTON" - ) { - (focused?.nextElementSibling as HTMLButtonElement)?.focus(); - } else if ( - e.key === "ArrowUp" && - focused?.previousElementSibling?.tagName === - "BUTTON" - ) { - (focused?.previousElementSibling as HTMLButtonElement)?.focus(); - - } else if (e.key === "Escape" && closeModal) { - closeModal(); - } else if (e.key === "Escape" && running) { - pause(true); - } else if (e.key.toLowerCase() === "m" && !alertsOpen) { - openSettingsPanel().then(); - } else if (e.key.toLowerCase() === "s" && !alertsOpen) { - openScorePanel().then(); - } else { - return; - } - e.preventDefault(); + const focused = document.querySelector("button:focus"); + if (e.key in pressed) { + setKeyPressed(e.key, 0); + } else if ( + e.key === "ArrowDown" && + focused?.nextElementSibling?.tagName === "BUTTON" + ) { + (focused?.nextElementSibling as HTMLButtonElement)?.focus(); + } else if ( + e.key === "ArrowUp" && + focused?.previousElementSibling?.tagName === "BUTTON" + ) { + (focused?.previousElementSibling as HTMLButtonElement)?.focus(); + } else if (e.key === "Escape" && closeModal) { + closeModal(); + } else if (e.key === "Escape" && running) { + pause(true); + } else if (e.key.toLowerCase() === "m" && !alertsOpen) { + openSettingsPanel().then(); + } else if (e.key.toLowerCase() === "s" && !alertsOpen) { + openScorePanel().then(); + } else { + return; + } + e.preventDefault(); }); + +function sample(arr:T[]):T{ + return arr[Math.floor(arr.length*Math.random())] +} + +function getMajorityValue(arr:string[]):string{ + const count = {} + arr.forEach(v=>count[v]=(count[v]||0)+1) + const max = Math.max(...Object.values(count)) + return sample(Object.keys(count).filter(k=>count[k]==max)) +} + + fitSize(); restart(); tick(); diff --git a/src/levels.json b/src/levels.json index 5997153..1726373 100644 --- a/src/levels.json +++ b/src/levels.json @@ -450,9 +450,15 @@ "svg": "" }, { - "name": "icon:sides_are_lava", + "name": "icon:left_is_lava", "size": 8, - "bricks": "r______rrttttttrrttttttrr______rr______rr____W_rr______rr_WWW__r", + "bricks": "r_______rtttttt_rtttttt_r_______r_______r____W__r_______r_WWW___", + "svg": "" + }, + { + "name": "icon:right_is_lava", + "size": 8, + "bricks": "_______r_ttttttr_ttttttr_______r_______r_____W_r_______r__WWW__r", "svg": "" }, { @@ -829,4 +835,4 @@ "bricks": "_W__W_WW__WW____________WW__WW_W__W_", "svg": "" } -] \ No newline at end of file +] diff --git a/src/options.ts b/src/options.ts index 413f038..9faa608 100644 --- a/src/options.ts +++ b/src/options.ts @@ -1,54 +1,54 @@ -import {fitSize, gameCanvas} from "./game"; +import { fitSize, gameCanvas } from "./game"; export const options = { - sound: { - default: true, - name: `Game sounds`, - help: `Can slow down some phones.`, - disabled: () => false, + sound: { + default: true, + name: `Game sounds`, + help: `Can slow down some phones.`, + disabled: () => false, + }, + "mobile-mode": { + default: window.innerHeight > window.innerWidth, + name: `Mobile mode`, + help: `Leaves space for your thumb.`, + afterChange() { + fitSize(); }, - "mobile-mode": { - default: window.innerHeight > window.innerWidth, - name: `Mobile mode`, - help: `Leaves space for your thumb.`, - afterChange() { - fitSize(); - }, - disabled: () => false, + disabled: () => false, + }, + basic: { + default: false, + name: `Basic graphics`, + help: `Better performance on older devices.`, + disabled: () => false, + }, + pointerLock: { + default: false, + name: `Mouse pointer lock`, + help: `Locks and hides the mouse cursor.`, + disabled: () => !gameCanvas.requestPointerLock, + }, + easy: { + default: false, + name: `Kids mode`, + help: `Start future runs with "slower ball".`, + disabled: () => false, + }, // Could not get the sharing to work without loading androidx and all the modern android things so for now i'll just disable sharing in the android app + record: { + default: false, + name: `Record gameplay videos`, + help: `Get a video of each level.`, + disabled() { + return window.location.search.includes("isInWebView=true"); }, - basic: { - default: false, - name: `Basic graphics`, - help: `Better performance on older devices.`, - disabled: () => false, - }, - pointerLock: { - default: false, - name: `Mouse pointer lock`, - help: `Locks and hides the mouse cursor.`, - disabled: () => !gameCanvas.requestPointerLock, - }, - easy: { - default: false, - name: `Kids mode`, - help: `Start future runs with "slower ball".`, - disabled: () => false, - }, // Could not get the sharing to work without loading androidx and all the modern android things so for now i'll just disable sharing in the android app - record: { - default: false, - name: `Record gameplay videos`, - help: `Get a video of each level.`, - disabled() { - return window.location.search.includes("isInWebView=true"); - }, - }, -} as {[k:string]:OptionDef} + }, +} as { [k: string]: OptionDef }; export type OptionDef = { - default:boolean; -name:string; -help:string; -disabled:()=>boolean - afterChange?:()=>void -} -export type OptionId = keyof (typeof options) \ No newline at end of file + default: boolean; + name: string; + help: string; + disabled: () => boolean; + afterChange?: () => void; +}; +export type OptionId = keyof typeof options; diff --git a/src/rawUpgrades.ts b/src/rawUpgrades.ts index 206e08d..15437a8 100644 --- a/src/rawUpgrades.ts +++ b/src/rawUpgrades.ts @@ -75,15 +75,31 @@ export const rawUpgrades = [ { requires: "", threshold: 0, - id: "sides_are_lava", + id: "left_is_lava", giftable: true, - name: "Shoot straight", + name: "Avoid left side", max: 1, - help: (lvl) => `More coins if you don't touch the sides.`, + help: (lvl) => `More coins if you don't touch the left side.`, - fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin all the next bricks you break. - However, your combo will reset as soon as your ball hits the left or right side. - As soon as your combo rises, the sides become red to remind you that you should avoid hitting them. The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any + fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break. + However, your combo will reset as soon as your ball hits the left side . + As soon as your combo rises, the left side becomes red to remind you that you should avoid hitting them. + The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any + of the reset conditions are met.`, + }, + { + requires: "", + threshold: 0, + id: "right_is_lava", + giftable: true, + name: "Avoid right side", + max: 1, + help: (lvl) => `More coins if you don't touch the right side.`, + + fullHelp: `Whenever you break a brick, your combo will increase by one, so you'll get one more coin from all the next bricks you break. + However, your combo will reset as soon as your ball hits the right side . + As soon as your combo rises, the right side becomes red to remind you that you should avoid hitting them. + The effect stacks with other combo perks, combo rises faster with more upgrades but will also reset if any of the reset conditions are met.`, }, { @@ -94,7 +110,6 @@ export const rawUpgrades = [ name: "Sky is the limit", max: 1, help: (lvl) => `More coins if you don't touch the top.`, - fullHelp: `Whenever you break a brick, your combo will increase by one. However, your combo will reset as soon as your ball hit the top of the screen. When your combo is above the minimum, a red bar will appear at the top to remind you that you should avoid hitting it. The effect stacks with other combo perks.`, diff --git a/src/style.css b/src/style.css index 31b8a92..01832ff 100644 --- a/src/style.css +++ b/src/style.css @@ -90,6 +90,15 @@ body { max-width: 450px; } +.popup.actionsAsGrid > div { + max-width: 800px; + + section { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + } +} + .popup > div > * { padding: 0; margin: 0; @@ -100,25 +109,58 @@ body { margin-bottom: 20px; } -.popup > div > button { - font: inherit; - background: rgba(0, 0, 0, 0.8); - color: white; - padding: 10px; - cursor: pointer; - border: 1px solid white; - text-align: left; +.popup > div > section { display: flex; - gap: 10px; - margin-top: -1px; + flex-direction: column; + align-items: stretch; + margin-top: 20px; + + button { + font: inherit; + background: rgba(0, 0, 0, 0.8); + color: white; + padding: 10px; + cursor: pointer; + border: 1px solid white; + text-align: left; + display: flex; + gap: 10px; + margin-top: -1px; + + &:not([disabled]):hover, + &:not([disabled]):focus { + border-color: #f1d33b; + position: relative; + z-index: 1; + } + + &[disabled] { + /*border: 1px solid #666;*/ + opacity: 0.5; + filter: saturate(0); + pointer-events: none; + } + + & > div { + flex-grow: 1; + } + + & > div > em { + display: block; + opacity: 0.8; + } + + &.grey-out-unless-hovered { + &:not(:hover) { + opacity: 0.6; + img { + filter: saturate(0); + } + } + } + } } -.popup > div > button:not([disabled]):hover, -.popup > div > button:not([disabled]):focus { - border-color: #f1d33b; - position: relative; - z-index: 1; -} .popup button.close-modale { color: white; @@ -131,61 +173,21 @@ body { border: none; cursor: pointer; overflow: hidden; -} -.popup button.close-modale:before { - content: "+"; - position: absolute; - transform: translate(-50%, -50%) rotate(45deg); - font-size: 80px; - display: inline-block; - top: 34px; - left: 26px; -} + &:before { + content: "+"; + position: absolute; + transform: translate(-50%, -50%) rotate(45deg); + font-size: 80px; + display: inline-block; + top: 34px; + left: 26px; + } -.popup button.close-modale:hover { - font-weight: bold; - background: black; -} - -.popup > div > button[disabled] { - /*border: 1px solid #666;*/ - opacity: 0.5; - filter: saturate(0); - pointer-events: none; -} - -.popup > div > button > div { - flex-grow: 1; -} - -.popup > div > button > div > em { - display: block; - opacity: 0.8; -} - -.popup > div > button > span.checks { - width: 40px; - height: 40px; - display: inline-flex; - gap: 5px; - flex-grow: 0; - flex-shrink: 0; -} - -.popup > div > button > span.checks > span { - flex-basis: 10px; - flex-grow: 1; - flex-shrink: 1; - /*border: 1px solid white;*/ - background: white; - opacity: 0.1; - border-radius: 4px; - align-self: stretch; -} - -.popup > div > button > span.checks > span.checked { - opacity: 1; + &:hover { + font-weight: bold; + background: black; + } } .popup .textAfterButtons { @@ -289,9 +291,11 @@ body { flex-direction: column; justify-content: flex-end; } + .histogram > span.active > span { background: #4049ca; } + .histogram > span > span { /*Visible bar*/ background: #1c1c2f; diff --git a/src/types.d.ts b/src/types.d.ts index 2476c34..992f36d 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,143 +1,139 @@ -import {rawUpgrades} from "./rawUpgrades"; +import { rawUpgrades } from "./rawUpgrades"; export type colorString = string; export type RawLevel = { - name: string; - size: number; - bricks: string; - svg: string; - color: string; + name: string; + size: number; + bricks: string; + svg: string; + color: string; }; export type Level = { - name: string; - size: number; - bricks: colorString[]; - svg: string; - color: string; - threshold?: number; - sortKey?: number; + name: string; + size: number; + bricks: colorString[]; + svg: string; + color: string; + threshold?: number; + sortKey?: number; }; export type Palette = { [k: string]: string }; export type Upgrade = { - threshold: number; - giftable: boolean; - id: PerkId; - name: string; - icon: string; - max: number; - help: (lvl: number) => string; - fullHelp: string; - requires: PerkId | ""; + threshold: number; + giftable: boolean; + id: PerkId; + name: string; + icon: string; + max: number; + help: (lvl: number) => string; + fullHelp: string; + requires: PerkId | ""; }; export type PerkId = (typeof rawUpgrades)[number]["id"]; declare global { - interface Window { - webkitAudioContext?: typeof AudioContext; - } + interface Window { + webkitAudioContext?: typeof AudioContext; + } - interface Document { - webkitFullscreenEnabled?: boolean; - webkitCancelFullScreen?: () => void; - } + interface Document { + webkitFullscreenEnabled?: boolean; + webkitCancelFullScreen?: () => void; + } - interface Element { - webkitRequestFullscreen: typeof Element.requestFullscreen - } - - interface MediaStream { - // https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html - // On firefox, the capture stream has the requestFrame option - // instead of the track, go figure - requestFrame?:()=>void - } + interface Element { + webkitRequestFullscreen: typeof Element.requestFullscreen; + } + interface MediaStream { + // https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html + // On firefox, the capture stream has the requestFrame option + // instead of the track, go figure + requestFrame?: () => void; + } } export type BallLike = { - x: number; - y: number; - vx?: number; - vy?: number; -} + x: number; + y: number; + vx?: number; + vy?: number; +}; export type Coin = { - points: number; - color: colorString; - x: number; - y: number; - previousx: number; - previousy: number; - vx: number; - vy: number; - sx: number; - sy: number; - a: number; - sa: number; - weight: number; - destroyed?: boolean; - coloredABrick?: boolean; -} + points: number; + color: colorString; + x: number; + y: number; + previousx: number; + previousy: number; + vx: number; + vy: number; + sx: number; + sy: number; + a: number; + sa: number; + weight: number; + destroyed?: boolean; + coloredABrick?: boolean; +}; export type Ball = { - x: number; - previousx: number; - y: number; - previousy: number; - vx: number; - vy: number; - sx: number; - sy: number; - sparks: number; - piercedSinceBounce: number; - hitSinceBounce: number; - hitItem: { index: number, color: string }[]; - bouncesList?: { x: number, y: number }[]; - sapperUses: number; - destroyed?: boolean; - previousvx?: number; - previousvy?: number; -} + x: number; + previousx: number; + y: number; + previousy: number; + vx: number; + vy: number; + sx: number; + sy: number; + sparks: number; + piercedSinceBounce: number; + hitSinceBounce: number; + hitItem: { index: number; color: string }[]; + bouncesList?: { x: number; y: number }[]; + sapperUses: number; + destroyed?: boolean; + previousvx?: number; + previousvy?: number; +}; - -export type FlashTypes = "text" | "particle" | 'ball' +export type FlashTypes = "text" | "particle" | "ball"; export type Flash = { - type: FlashTypes; - text?: string; - time: number; - color: colorString; - x: number; - y: number; - duration: number; - size: number; - vx?: number; - vy?: number; - ethereal?: boolean; - destroyed?: boolean; -} + type: FlashTypes; + text?: string; + time: number; + color: colorString; + x: number; + y: number; + duration: number; + size: number; + vx?: number; + vy?: number; + ethereal?: boolean; + destroyed?: boolean; +}; -export type RunStats= { - started: number; - levelsPlayed: number; - runTime: number; - coins_spawned: number; - score: number; - bricks_broken: number; - misses: number; - balls_lost: number; - puck_bounces: number; - upgrades_picked: number; - max_combo: number; - max_level: number; -} - - -export type RunHistoryItem =RunStats & { - perks?: {[k in PerkId]:number}; - appVersion?:string; -} +export type RunStats = { + started: number; + levelsPlayed: number; + runTime: number; + coins_spawned: number; + score: number; + bricks_broken: number; + misses: number; + balls_lost: number; + puck_bounces: number; + upgrades_picked: number; + max_combo: number; + max_level: number; +}; +export type RunHistoryItem = RunStats & { + perks?: { [k in PerkId]: number }; + appVersion?: string; +};