diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72f1e72..e05b412 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "me.lecaro.breakout" minSdk = 21 targetSdk = 34 - versionCode = 29040074 - versionName = "29040074" + versionCode = 29040298 + versionName = "29040298" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true diff --git a/app/src/main/assets/index.html b/app/src/main/assets/index.html index 2185742..e26198d 100644 --- a/app/src/main/assets/index.html +++ b/app/src/main/assets/index.html @@ -1 +1 @@ -Breakout 71 \ No newline at end of file +Breakout 71 \ No newline at end of file diff --git a/dist/index.html b/dist/index.html index 563ee4e..f5833b6 100644 --- a/dist/index.html +++ b/dist/index.html @@ -1270,7 +1270,7 @@ const upgrades = (0, _upgrades.rawUpgrades).map((u)=>({ })); },{"./data/palette.json":"ktRBU","./data/levels.json":"8JSUc","./data/version.json":"iyP6E","./getLevelBackground":"7OIPf","./levelIcon":"6rQoT","@parcel/transformer-js/src/esmodule-helpers.js":"gkKU3","./upgrades":"1u3Dx"}],"iyP6E":[function(require,module,exports,__globalThis) { -module.exports = JSON.parse("\"29040074\""); +module.exports = JSON.parse("\"29040298\""); },{}],"6rQoT":[function(require,module,exports,__globalThis) { var parcelHelpers = require("@parcel/transformer-js/src/esmodule-helpers.js"); @@ -2402,7 +2402,7 @@ function spawnExplosion(gameState, count, x, y, color) { for(let i = 0; i < count; i++)makeParticle(gameState, x + (Math.random() - 0.5) * gameState.brickWidth / 2, y + (Math.random() - 0.5) * gameState.brickWidth / 2, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 30, color, false); } function explosionAt(gameState, index, x, y, ball) { - if (gameState.bricks[index] == 'black') delete gameState.bricks[index]; + if (gameState.bricks[index] == "black") delete gameState.bricks[index]; schedulGameSound(gameState, "explode", ball.x, 1); const col = index % gameState.gridSize; const row = Math.floor(index / gameState.gridSize); @@ -2710,7 +2710,7 @@ frames = 1) { if (!gameState.perks.etherealcoins) { const flip = gameState.perks.helium > 0 && Math.abs(coin.x - gameState.puckPosition) * 2 > gameState.puckWidth + coin.size; coin.vy += frames * coin.weight * 0.8 * (flip ? -1 : 1); - if (flip && !(0, _options.isOptionOn)('basic') && Math.random() < 0.1) makeParticle(gameState, coin.x, coin.y, 0, gameState.baseSpeed, rainbowColor(), true, 5, 250); + if (flip && !(0, _options.isOptionOn)("basic") && Math.random() < 0.1) makeParticle(gameState, coin.x, coin.y, 0, gameState.baseSpeed, rainbowColor(), true, 5, 250); } const speed = Math.abs(coin.sx) + Math.abs(coin.sx); const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); diff --git a/src/PWA/sw-b71.js b/src/PWA/sw-b71.js index 60a6c38..5f284a4 100644 --- a/src/PWA/sw-b71.js +++ b/src/PWA/sw-b71.js @@ -1,5 +1,5 @@ // The version of the cache. -const VERSION = "29040074"; +const VERSION = "29040298"; // The name of the cache const CACHE_NAME = `breakout-71-${VERSION}`; diff --git a/src/data/levels.json b/src/data/levels.json index ac4a265..20ebb88 100644 --- a/src/data/levels.json +++ b/src/data/levels.json @@ -940,4 +940,4 @@ "svg": null, "color": "" } -] \ No newline at end of file +] diff --git a/src/data/version.json b/src/data/version.json index 06ae602..e13484e 100644 --- a/src/data/version.json +++ b/src/data/version.json @@ -1 +1 @@ -"29040074" +"29040298" diff --git a/src/game.ts b/src/game.ts index 6444d32..cefd415 100644 --- a/src/game.ts +++ b/src/game.ts @@ -1,4 +1,4 @@ -import {allLevels, appVersion, icons, upgrades} from "./loadGameData"; +import { allLevels, appVersion, icons, upgrades } from "./loadGameData"; import { Ball, Coin, @@ -11,12 +11,17 @@ import { TextFlash, Upgrade, } from "./types"; -import {getAudioContext, playPendingSounds} from "./sounds"; -import {currentLevelInfo, getRowColIndex, max_levels, pickedUpgradesHTMl,} from "./game_utils"; +import { getAudioContext, playPendingSounds } from "./sounds"; +import { + currentLevelInfo, + getRowColIndex, + max_levels, + pickedUpgradesHTMl, +} from "./game_utils"; import "./PWA/sw_loader"; -import {getCurrentLang, t} from "./i18n/i18n"; -import {getSettingValue, getTotalScore, setSettingValue} from "./settings"; +import { getCurrentLang, t } from "./i18n/i18n"; +import { getSettingValue, getTotalScore, setSettingValue } from "./settings"; import { forEachLiveOne, gameStateTick, @@ -25,12 +30,28 @@ import { setLevel, setMousePos, } from "./gameStateMutators"; -import {backgroundCanvas, ctx, gameCanvas, render, scoreDisplay,} from "./render"; -import {pauseRecording, recordOneFrame, resumeRecording, startRecordingGame,} from "./recording"; -import {newGameState} from "./newGameState"; -import {alertsOpen, asyncAlert, AsyncAlertAction, closeModal,} from "./asyncAlert"; -import {isOptionOn, options, toggleOption} from "./options"; -import {hashCode} from "./getLevelBackground"; +import { + backgroundCanvas, + ctx, + gameCanvas, + render, + scoreDisplay, +} from "./render"; +import { + pauseRecording, + recordOneFrame, + resumeRecording, + startRecordingGame, +} from "./recording"; +import { newGameState } from "./newGameState"; +import { + alertsOpen, + asyncAlert, + AsyncAlertAction, + closeModal, +} from "./asyncAlert"; +import { isOptionOn, options, toggleOption } from "./options"; +import { hashCode } from "./getLevelBackground"; export function play() { if (gameState.running) return; @@ -406,8 +427,7 @@ async function openScorePanel() { async function openSettingsPanel() { pause(true); - const actions: AsyncAlertAction<() => void>[] = [ - ]; + const actions: AsyncAlertAction<() => void>[] = []; for (const key of Object.keys(options) as OptionId[]) { if (options[key]) @@ -449,21 +469,18 @@ async function openSettingsPanel() { }); } } - actions.push( - { - text: t("main_menu.resume"), - help: t("main_menu.resume_help"), - value() {}, - }) actions.push({ - text: t("main_menu.unlocks"), - help: t("main_menu.unlocks_help"), - value() { - openUnlocksList(); - }, - }) - - + text: t("main_menu.resume"), + help: t("main_menu.resume_help"), + value() {}, + }); + actions.push({ + text: t("main_menu.unlocks"), + help: t("main_menu.unlocks_help"), + value() { + openUnlocksList(); + }, + }); actions.push({ text: t("sandbox.title"), diff --git a/src/gameStateMutators.ts b/src/gameStateMutators.ts index 343b869..538036e 100644 --- a/src/gameStateMutators.ts +++ b/src/gameStateMutators.ts @@ -1,1578 +1,1616 @@ import { - Ball, - BallLike, - Coin, - colorString, - GameState, - LightFlash, - ParticleFlash, - PerkId, - ReusableArray, - TextFlash, + Ball, + BallLike, + Coin, + colorString, + GameState, + LightFlash, + ParticleFlash, + PerkId, + ReusableArray, + TextFlash, } from "./types"; import { - brickCenterX, - brickCenterY, - clamp, - currentLevelInfo, - distance2, - distanceBetween, - getMajorityValue, - getPossibleUpgrades, - getRowColIndex, - isTelekinesisActive, - isYoyoActive, - max_levels, sample, - shouldPierceByColor, + brickCenterX, + brickCenterY, + clamp, + currentLevelInfo, + distance2, + distanceBetween, + getMajorityValue, + getPossibleUpgrades, + getRowColIndex, + isTelekinesisActive, + isYoyoActive, + max_levels, + sample, + shouldPierceByColor, } from "./game_utils"; -import {t} from "./i18n/i18n"; -import {icons} from "./loadGameData"; +import { t } from "./i18n/i18n"; +import { icons } from "./loadGameData"; -import {addToTotalScore} from "./settings"; -import {background} from "./render"; -import {gameOver} from "./gameOver"; -import {brickIndex, fitSize, gameState, hasBrick, hitsSomething, openUpgradesPicker, pause,} from "./game"; -import {stopRecording} from "./recording"; -import {isOptionOn} from "./options"; +import { addToTotalScore } from "./settings"; +import { background } from "./render"; +import { gameOver } from "./gameOver"; +import { + brickIndex, + fitSize, + gameState, + hasBrick, + hitsSomething, + openUpgradesPicker, + pause, +} from "./game"; +import { stopRecording } from "./recording"; +import { isOptionOn } from "./options"; export function setMousePos(gameState: GameState, x: number) { - // Sets the puck position, and updates the ball position if they are supposed to follow it - gameState.puckPosition = x; - gameState.needsRender = true; + // Sets the puck position, and updates the ball position if they are supposed to follow it + gameState.puckPosition = x; + gameState.needsRender = true; } function getBallDefaultVx(gameState: GameState) { - return ( - (gameState.perks.concave_puck ? 0 : 1) * - (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed) - ); + return ( + (gameState.perks.concave_puck ? 0 : 1) * + (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed) + ); } export function resetBalls(gameState: GameState) { - const count = 1 + (gameState.perks?.multiball || 0); - const perBall = gameState.puckWidth / (count + 1); - gameState.balls = []; - gameState.ballsColor = "#FFF"; - if (gameState.perks.picky_eater || gameState.perks.pierce_color) { - gameState.ballsColor = - getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF"; - } - for (let i = 0; i < count; i++) { - const x = - gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); - const vx = getBallDefaultVx(gameState); + const count = 1 + (gameState.perks?.multiball || 0); + const perBall = gameState.puckWidth / (count + 1); + gameState.balls = []; + gameState.ballsColor = "#FFF"; + if (gameState.perks.picky_eater || gameState.perks.pierce_color) { + gameState.ballsColor = + getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF"; + } + for (let i = 0; i < count; i++) { + const x = + gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); + const vx = getBallDefaultVx(gameState); - gameState.balls.push({ - x, - previousX: x, - y: gameState.gameZoneHeight - 1.5 * gameState.ballSize, - previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize, - vx, - previousVX: vx, - vy: -gameState.baseSpeed, - previousVY: -gameState.baseSpeed, + gameState.balls.push({ + x, + previousX: x, + y: gameState.gameZoneHeight - 1.5 * gameState.ballSize, + previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize, + vx, + previousVX: vx, + vy: -gameState.baseSpeed, + previousVY: -gameState.baseSpeed, - sx: 0, - sy: 0, - piercedSinceBounce: 0, - hitSinceBounce: 0, - hitItem: [], - sapperUses: 0, - }); - } - gameState.ballStickToPuck = true; + sx: 0, + sy: 0, + piercedSinceBounce: 0, + hitSinceBounce: 0, + hitItem: [], + sapperUses: 0, + }); + } + gameState.ballStickToPuck = true; } export function putBallsAtPuck(gameState: GameState) { - // This reset could be abused to cheat quite easily - const count = gameState.balls.length; - const perBall = gameState.puckWidth / (count + 1); - const vx = getBallDefaultVx(gameState); - gameState.balls.forEach((ball, i) => { - const x = - gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); + // This reset could be abused to cheat quite easily + const count = gameState.balls.length; + const perBall = gameState.puckWidth / (count + 1); + const vx = getBallDefaultVx(gameState); + gameState.balls.forEach((ball, i) => { + const x = + gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); - ball.x = x; - ball.previousX = x; - ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize; - ball.previousY = ball.y; - ball.vx = vx; - ball.previousVX = ball.vx; - ball.vy = -gameState.baseSpeed; - ball.previousVY = ball.vy; - ball.sx = 0; - ball.sy = 0; - ball.hitItem = []; - ball.hitSinceBounce = 0; - ball.piercedSinceBounce = 0; - }); + ball.x = x; + ball.previousX = x; + ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize; + ball.previousY = ball.y; + ball.vx = vx; + ball.previousVX = ball.vx; + ball.vy = -gameState.baseSpeed; + ball.previousVY = ball.vy; + ball.sx = 0; + ball.sy = 0; + ball.hitItem = []; + ball.hitSinceBounce = 0; + ball.piercedSinceBounce = 0; + }); } export function normalizeGameState(gameState: GameState) { - // This function resets most parameters on the state to correct values, and should be used even when the game is paused + // This function resets most parameters on the state to correct values, and should be used even when the game is paused - gameState.baseSpeed = Math.max( - 3, - gameState.gameZoneWidth / 12 / 10 + - gameState.currentLevel / 3 + - gameState.levelTime / (30 * 1000) - - gameState.perks.slow_down * 2, - ); + gameState.baseSpeed = Math.max( + 3, + gameState.gameZoneWidth / 12 / 10 + + gameState.currentLevel / 3 + + gameState.levelTime / (30 * 1000) - + gameState.perks.slow_down * 2, + ); - gameState.puckWidth = - (gameState.gameZoneWidth / 12) * - (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); + gameState.puckWidth = + (gameState.gameZoneWidth / 12) * + (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); - if ( - gameState.puckPosition < - gameState.offsetXRoundedDown + gameState.puckWidth / 2 - ) { - gameState.puckPosition = - gameState.offsetXRoundedDown + gameState.puckWidth / 2; - } - if ( - gameState.puckPosition > - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp - - gameState.puckWidth / 2 - ) { - gameState.puckPosition = - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp - - gameState.puckWidth / 2; - } - if (gameState.ballStickToPuck) { - putBallsAtPuck(gameState); - } + if ( + gameState.puckPosition < + gameState.offsetXRoundedDown + gameState.puckWidth / 2 + ) { + gameState.puckPosition = + gameState.offsetXRoundedDown + gameState.puckWidth / 2; + } + if ( + gameState.puckPosition > + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp - + gameState.puckWidth / 2 + ) { + gameState.puckPosition = + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp - + gameState.puckWidth / 2; + } + if (gameState.ballStickToPuck) { + putBallsAtPuck(gameState); + } } export function baseCombo(gameState: GameState) { - return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; + return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; } export function resetCombo( - gameState: GameState, - x: number | undefined, - y: number | undefined, + gameState: GameState, + x: number | undefined, + y: number | undefined, ) { - const prev = gameState.combo; - gameState.combo = baseCombo(gameState); + const prev = gameState.combo; + gameState.combo = baseCombo(gameState); - if (prev > gameState.combo && gameState.perks.soft_reset) { - gameState.combo += Math.floor( - ((prev - gameState.combo) * (gameState.perks.soft_reset * 10)) / 100, - ); + if (prev > gameState.combo && gameState.perks.soft_reset) { + gameState.combo += Math.floor( + ((prev - gameState.combo) * (gameState.perks.soft_reset * 10)) / 100, + ); + } + const lost = Math.max(0, prev - gameState.combo); + if (lost) { + for (let i = 0; i < lost && i < 8; i++) { + setTimeout( + () => schedulGameSound(gameState, "comboDecrease", x, 1), + i * 100, + ); } - const lost = Math.max(0, prev - gameState.combo); - if (lost) { - for (let i = 0; i < lost && i < 8; i++) { - setTimeout( - () => schedulGameSound(gameState, "comboDecrease", x, 1), - i * 100, - ); - } - if (typeof x !== "undefined" && typeof y !== "undefined") { - makeText(gameState, x, y, "red", "-" + lost, 20, 150); - } + if (typeof x !== "undefined" && typeof y !== "undefined") { + makeText(gameState, x, y, "red", "-" + lost, 20, 150); } - return lost; + } + return lost; } export function decreaseCombo( - gameState: GameState, - by: number, - x: number, - y: number, + gameState: GameState, + by: number, + x: number, + y: number, ) { - const prev = gameState.combo; - gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by); - const lost = Math.max(0, prev - gameState.combo); + const prev = gameState.combo; + gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by); + const lost = Math.max(0, prev - gameState.combo); - if (lost) { - schedulGameSound(gameState, "comboDecrease", x, 1); - if (typeof x !== "undefined" && typeof y !== "undefined") { - makeText(gameState, x, y, "red", "-" + lost, 20, 300); - } + if (lost) { + schedulGameSound(gameState, "comboDecrease", x, 1); + if (typeof x !== "undefined" && typeof y !== "undefined") { + makeText(gameState, x, y, "red", "-" + lost, 20, 300); } + } } export function spawnExplosion( - gameState: GameState, - count: number, - x: number, - y: number, - color: string, + gameState: GameState, + count: number, + x: number, + y: number, + color: string, ) { - if (!!isOptionOn("basic")) return; + if (!!isOptionOn("basic")) return; - if (liveCount(gameState.particles) > gameState.MAX_PARTICLES) { - // Avoid freezing when lots of explosion happen at once - count = 1; - } - for (let i = 0; i < count; i++) { - makeParticle( - gameState, + if (liveCount(gameState.particles) > gameState.MAX_PARTICLES) { + // Avoid freezing when lots of explosion happen at once + count = 1; + } + for (let i = 0; i < count; i++) { + makeParticle( + gameState, - x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, - (Math.random() - 0.5) * 30, - (Math.random() - 0.5) * 30, - color, - false, - ); - } + x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, + (Math.random() - 0.5) * 30, + (Math.random() - 0.5) * 30, + color, + false, + ); + } } export function explosionAt( - gameState: GameState, - index: number, - x: number, y: number, - ball: Ball) { + gameState: GameState, + index: number, + x: number, + y: number, + ball: Ball, +) { + if (gameState.bricks[index] == "black") delete gameState.bricks[index]; - if(gameState.bricks[index]=='black') - delete gameState.bricks[index]; + schedulGameSound(gameState, "explode", ball.x, 1); - - schedulGameSound(gameState, "explode", ball.x, 1); - - const col = index % gameState.gridSize; - const row = Math.floor(index / gameState.gridSize); - const size = 1 + gameState.perks.bigger_explosions; - // Break bricks around - for (let dx = -size; dx <= size; dx++) { - for (let dy = -size; dy <= size; dy++) { - const i = getRowColIndex(gameState, row + dy, col + dx); - if (gameState.bricks[i] && i !== -1) { - // Study bricks resist explosions too - if ( - gameState.bricks[i] !== "black" && - gameState.perks.sturdy_bricks > Math.random() * 5 - ) - continue; - explodeBrick(gameState, i, ball, true); - } - } + const col = index % gameState.gridSize; + const row = Math.floor(index / gameState.gridSize); + const size = 1 + gameState.perks.bigger_explosions; + // Break bricks around + for (let dx = -size; dx <= size; dx++) { + for (let dy = -size; dy <= size; dy++) { + const i = getRowColIndex(gameState, row + dy, col + dx); + if (gameState.bricks[i] && i !== -1) { + // Study bricks resist explosions too + if ( + gameState.bricks[i] !== "black" && + gameState.perks.sturdy_bricks > Math.random() * 5 + ) + continue; + explodeBrick(gameState, i, ball, true); + } } + } - // Blow nearby coins - forEachLiveOne(gameState.coins, (c) => { - const dx = c.x - x; - const dy = c.y - y; - const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy)); - c.vx += ((dx / d2) * 10 * size) / c.weight; - c.vy += ((dy / d2) * 10 * size) / c.weight; - }); - gameState.lastExplosion = Date.now(); + // Blow nearby coins + forEachLiveOne(gameState.coins, (c) => { + const dx = c.x - x; + const dy = c.y - y; + const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy)); + c.vx += ((dx / d2) * 10 * size) / c.weight; + c.vy += ((dy / d2) * 10 * size) / c.weight; + }); + gameState.lastExplosion = Date.now(); - makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150); + makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150); - spawnExplosion( - gameState, - 7 * (1 + gameState.perks.bigger_explosions), - x, - y, - "white", - ); - gameState.runStatistics.bricks_broken++; + spawnExplosion( + gameState, + 7 * (1 + gameState.perks.bigger_explosions), + x, + y, + "white", + ); + gameState.runStatistics.bricks_broken++; - if(gameState.perks.zen){ - resetCombo(gameState, x,y) - } + if (gameState.perks.zen) { + resetCombo(gameState, x, y); + } } export function explodeBrick( - gameState: GameState, - index: number, - ball: Ball, - isExplosion: boolean, + gameState: GameState, + index: number, + ball: Ball, + isExplosion: boolean, ) { - const color = gameState.bricks[index]; - if (!color) return; + const color = gameState.bricks[index]; + if (!color) return; - if (color === "black") { - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); - explosionAt(gameState, - index, x, y, - ball) + if (color === "black") { + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); + explosionAt(gameState, index, x, y, ball); + } else if (color) { + // Even if it bounces we don't want to count that as a miss - } else if (color) { - // Even if it bounces we don't want to count that as a miss + // Flashing is take care of by the tick loop + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); - // Flashing is take care of by the tick loop - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); + gameState.bricks[index] = ""; - gameState.bricks[index] = ""; - - let coinsToSpawn = gameState.combo; - if (gameState.perks.sturdy_bricks) { - // +10% per level - coinsToSpawn += Math.ceil( - ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn, - ); - } - - gameState.levelSpawnedCoins += coinsToSpawn; - gameState.runStatistics.coins_spawned += coinsToSpawn; - gameState.runStatistics.bricks_broken++; - const maxCoins = gameState.MAX_COINS * (isOptionOn("basic") ? 0.5 : 1); - const spawnableCoins = - liveCount(gameState.coins) > gameState.MAX_COINS - ? 1 - : Math.floor(maxCoins - liveCount(gameState.coins)) / 3; - - const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); - - while (coinsToSpawn > 0) { - const points = Math.min(pointsPerCoin, coinsToSpawn); - if (points < 0 || isNaN(points)) { - console.error({points}); - debugger; - } - - coinsToSpawn -= points; - - const cx = - x + - (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize), - cy = - y + - (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize); - makeCoin( - gameState, - cx, - cy, - ball.previousVX * (0.5 + Math.random()), - ball.previousVY * (0.5 + Math.random()), - gameState.perks.metamorphosis ? color : "gold", - points, - ); - } - - gameState.combo += gameState.perks.streak_shots + - gameState.perks.compound_interest + - gameState.perks.left_is_lava + - gameState.perks.right_is_lava + - gameState.perks.top_is_lava + - gameState.perks.picky_eater + - gameState.perks.asceticism + - gameState.perks.zen + - gameState.perks.unbounded - - ; - - if (!isExplosion) { - // color change - if ( - (gameState.perks.picky_eater || gameState.perks.pierce_color) && - color !== gameState.ballsColor && - color - ) { - if (gameState.perks.picky_eater) { - resetCombo(gameState, ball.x, ball.y); - } - schedulGameSound(gameState, "colorChange", ball.x, 0.8); - gameState.lastExplosion = gameState.levelTime; - gameState.ballsColor = color; - if (!isOptionOn("basic")) { - gameState.balls.forEach((ball) => { - spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color); - }); - } - } else { - schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1); - } - } - makeLight(gameState, x, y, color, gameState.brickWidth, 40); - - spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color); + let coinsToSpawn = gameState.combo; + if (gameState.perks.sturdy_bricks) { + // +10% per level + coinsToSpawn += Math.ceil( + ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn, + ); } - if (!gameState.bricks[index] && color !== "black") { - ball.hitItem?.push({ - index, - color, - }); + gameState.levelSpawnedCoins += coinsToSpawn; + gameState.runStatistics.coins_spawned += coinsToSpawn; + gameState.runStatistics.bricks_broken++; + const maxCoins = gameState.MAX_COINS * (isOptionOn("basic") ? 0.5 : 1); + const spawnableCoins = + liveCount(gameState.coins) > gameState.MAX_COINS + ? 1 + : Math.floor(maxCoins - liveCount(gameState.coins)) / 3; + + const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); + + while (coinsToSpawn > 0) { + const points = Math.min(pointsPerCoin, coinsToSpawn); + if (points < 0 || isNaN(points)) { + console.error({ points }); + debugger; + } + + coinsToSpawn -= points; + + const cx = + x + + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize), + cy = + y + + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize); + makeCoin( + gameState, + cx, + cy, + ball.previousVX * (0.5 + Math.random()), + ball.previousVY * (0.5 + Math.random()), + gameState.perks.metamorphosis ? color : "gold", + points, + ); } + + gameState.combo += + gameState.perks.streak_shots + + gameState.perks.compound_interest + + gameState.perks.left_is_lava + + gameState.perks.right_is_lava + + gameState.perks.top_is_lava + + gameState.perks.picky_eater + + gameState.perks.asceticism + + gameState.perks.zen + + gameState.perks.unbounded; + + if (!isExplosion) { + // color change + if ( + (gameState.perks.picky_eater || gameState.perks.pierce_color) && + color !== gameState.ballsColor && + color + ) { + if (gameState.perks.picky_eater) { + resetCombo(gameState, ball.x, ball.y); + } + schedulGameSound(gameState, "colorChange", ball.x, 0.8); + gameState.lastExplosion = gameState.levelTime; + gameState.ballsColor = color; + if (!isOptionOn("basic")) { + gameState.balls.forEach((ball) => { + spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color); + }); + } + } else { + schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1); + } + } + makeLight(gameState, x, y, color, gameState.brickWidth, 40); + + spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color); + } + + if (!gameState.bricks[index] && color !== "black") { + ball.hitItem?.push({ + index, + color, + }); + } } export function dontOfferTooSoon(gameState: GameState, id: PerkId) { - gameState.lastOffered[id] = Math.round(Date.now() / 1000); + gameState.lastOffered[id] = Math.round(Date.now() / 1000); } export function pickRandomUpgrades(gameState: GameState, count: number) { - let list = getPossibleUpgrades(gameState) - .map((u) => ({ - ...u, - score: Math.random() + (gameState.lastOffered[u.id] || 0), - })) - .sort((a, b) => a.score - b.score) - .filter((u) => gameState.perks[u.id] < u.max) - .slice(0, count) - .sort((a, b) => (a.id > b.id ? 1 : -1)); + let list = getPossibleUpgrades(gameState) + .map((u) => ({ + ...u, + score: Math.random() + (gameState.lastOffered[u.id] || 0), + })) + .sort((a, b) => a.score - b.score) + .filter((u) => gameState.perks[u.id] < u.max) + .slice(0, count) + .sort((a, b) => (a.id > b.id ? 1 : -1)); - list.forEach((u) => { - dontOfferTooSoon(gameState, u.id); - }); + list.forEach((u) => { + dontOfferTooSoon(gameState, u.id); + }); - return list.map((u) => ({ - text: - u.name + - (gameState.perks[u.id] - ? t("level_up.upgrade_perk_to_level", { - level: gameState.perks[u.id] + 1, - }) - : ""), - icon: icons["icon:" + u.id], - value: u.id as PerkId, - help: u.help(gameState.perks[u.id] + 1), - })); + return list.map((u) => ({ + text: + u.name + + (gameState.perks[u.id] + ? t("level_up.upgrade_perk_to_level", { + level: gameState.perks[u.id] + 1, + }) + : ""), + icon: icons["icon:" + u.id], + value: u.id as PerkId, + help: u.help(gameState.perks[u.id] + 1), + })); } export function schedulGameSound( - gameState: GameState, - sound: keyof GameState["aboutToPlaySound"], - x: number | void, - vol: number, + gameState: GameState, + sound: keyof GameState["aboutToPlaySound"], + x: number | void, + vol: number, ) { - if (!vol) return; - x ??= gameState.offsetX + gameState.gameZoneWidth / 2; - const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number }; + if (!vol) return; + x ??= gameState.offsetX + gameState.gameZoneWidth / 2; + const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number }; - ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol); - ex.vol += vol; + ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol); + ex.vol += vol; } export function addToScore(gameState: GameState, coin: Coin) { - gameState.score += coin.points; - gameState.lastScoreIncrease = gameState.levelTime; + gameState.score += coin.points; + gameState.lastScoreIncrease = gameState.levelTime; - addToTotalScore(gameState, coin.points); - if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { - gameState.highScore = gameState.score; - localStorage.setItem("breakout-3-hs", gameState.score.toString()); - } - if (!isOptionOn("basic")) { - makeParticle( - gameState, - coin.previousX, - coin.previousY, - (gameState.canvasWidth - coin.x) / 100, - -coin.y / 100, - coin.color, - true, - gameState.coinSize / 2, - 100 + Math.random() * 50, - ); - } + addToTotalScore(gameState, coin.points); + if (gameState.score > gameState.highScore && !gameState.isCreativeModeRun) { + gameState.highScore = gameState.score; + localStorage.setItem("breakout-3-hs", gameState.score.toString()); + } + if (!isOptionOn("basic")) { + makeParticle( + gameState, + coin.previousX, + coin.previousY, + (gameState.canvasWidth - coin.x) / 100, + -coin.y / 100, + coin.color, + true, + gameState.coinSize / 2, + 100 + Math.random() * 50, + ); + } - if (Date.now() - gameState.lastPlayedCoinGrab > 16) { - gameState.lastPlayedCoinGrab = Date.now(); + if (Date.now() - gameState.lastPlayedCoinGrab > 16) { + gameState.lastPlayedCoinGrab = Date.now(); - schedulGameSound(gameState, "coinCatch", coin.x, 1); - } - gameState.runStatistics.score += coin.points; - if (gameState.perks.asceticism) { - resetCombo(gameState, coin.x, coin.y) - } + schedulGameSound(gameState, "coinCatch", coin.x, 1); + } + gameState.runStatistics.score += coin.points; + if (gameState.perks.asceticism) { + resetCombo(gameState, coin.x, coin.y); + } } export async function setLevel(gameState: GameState, l: number) { - stopRecording(); - pause(false); - if (l > 0) { - await openUpgradesPicker(gameState); - } - gameState.currentLevel = l; - gameState.levelTime = 0; - gameState.noBricksSince = 0; - gameState.levelWallBounces = 0; - gameState.autoCleanUses = 0; - gameState.lastTickDown = gameState.levelTime; - gameState.levelStartScore = gameState.score; - gameState.levelSpawnedCoins = 0; - gameState.levelMisses = 0; - gameState.runStatistics.levelsPlayed++; + stopRecording(); + pause(false); + if (l > 0) { + await openUpgradesPicker(gameState); + } + gameState.currentLevel = l; + gameState.levelTime = 0; + gameState.noBricksSince = 0; + gameState.levelWallBounces = 0; + gameState.autoCleanUses = 0; + gameState.lastTickDown = gameState.levelTime; + gameState.levelStartScore = gameState.score; + gameState.levelSpawnedCoins = 0; + gameState.levelMisses = 0; + gameState.runStatistics.levelsPlayed++; - // Reset combo silently - if (!gameState.perks.shunt) { - gameState.combo = baseCombo(gameState) - } - gameState.combo += gameState.perks.hot_start * 15; + // Reset combo silently + if (!gameState.perks.shunt) { + gameState.combo = baseCombo(gameState); + } + gameState.combo += gameState.perks.hot_start * 15; - resetBalls(gameState); + resetBalls(gameState); - const lvl = currentLevelInfo(gameState); - if (lvl.size !== gameState.gridSize) { - gameState.gridSize = lvl.size; - fitSize(); - } - empty(gameState.coins); - empty(gameState.particles); - empty(gameState.lights); - empty(gameState.texts); - gameState.bricks = [...lvl.bricks]; - gameState.needsRender = true; - // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons - // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) - background.src = "data:image/svg+xml;UTF8," + lvl.svg; + const lvl = currentLevelInfo(gameState); + if (lvl.size !== gameState.gridSize) { + gameState.gridSize = lvl.size; + fitSize(); + } + empty(gameState.coins); + empty(gameState.particles); + empty(gameState.lights); + empty(gameState.texts); + gameState.bricks = [...lvl.bricks]; + gameState.needsRender = true; + // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons + // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) + background.src = "data:image/svg+xml;UTF8," + lvl.svg; } export function rainbowColor(): colorString { - return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; + return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; } export function repulse( - gameState: GameState, - a: Ball, - b: BallLike, - power: number, - impactsBToo: boolean, + gameState: GameState, + a: Ball, + b: BallLike, + power: number, + impactsBToo: boolean, ) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const max = gameState.gameZoneWidth / 4; - if (distance > max) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; - const fact = - (((-power * (max - distance)) / (max * 1.2) / 3) * - Math.min(500, gameState.levelTime)) / - 500; - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - b.vx += dx * fact; - b.vy += dy * fact; - } - a.vx -= dx * fact; - a.vy -= dy * fact; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const max = gameState.gameZoneWidth / 4; + if (distance > max) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; + const fact = + (((-power * (max - distance)) / (max * 1.2) / 3) * + Math.min(500, gameState.levelTime)) / + 500; + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { + b.vx += dx * fact; + b.vy += dy * fact; + } + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; + makeParticle( + gameState, + a.x, + a.y, + -dx * speed + a.vx + (Math.random() - 0.5) * rand, + -dy * speed + a.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); + if ( + impactsBToo && + typeof b.vx !== "undefined" && + typeof b.vy !== "undefined" + ) { makeParticle( - gameState, - a.x, - a.y, - -dx * speed + a.vx + (Math.random() - 0.5) * rand, - -dy * speed + a.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, + gameState, + b.x, + b.y, + dx * speed + b.vx + (Math.random() - 0.5) * rand, + dy * speed + b.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, ); - if ( - impactsBToo && - typeof b.vx !== "undefined" && - typeof b.vy !== "undefined" - ) { - makeParticle( - gameState, - b.x, - b.y, - dx * speed + b.vx + (Math.random() - 0.5) * rand, - dy * speed + b.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); - } + } } export function attract(gameState: GameState, a: Ball, b: Ball, power: number) { - const distance = distanceBetween(a, b); - // Ensure we don't get soft locked - const min = (gameState.gameZoneWidth * 3) / 4; - if (distance < min) return; - // Unit vector - const dx = (a.x - b.x) / distance; - const dy = (a.y - b.y) / distance; + const distance = distanceBetween(a, b); + // Ensure we don't get soft locked + const min = (gameState.gameZoneWidth * 3) / 4; + if (distance < min) return; + // Unit vector + const dx = (a.x - b.x) / distance; + const dy = (a.y - b.y) / distance; - const fact = - (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) / - 500; - b.vx += dx * fact; - b.vy += dy * fact; - a.vx -= dx * fact; - a.vy -= dy * fact; + const fact = + (((power * (distance - min)) / min) * Math.min(500, gameState.levelTime)) / + 500; + b.vx += dx * fact; + b.vy += dy * fact; + a.vx -= dx * fact; + a.vy -= dy * fact; - const speed = 10; - const rand = 2; + const speed = 10; + const rand = 2; - makeParticle( - gameState, - a.x, - a.y, - dx * speed + a.vx + (Math.random() - 0.5) * rand, - dy * speed + a.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); - makeParticle( - gameState, - b.x, - b.y, - -dx * speed + b.vx + (Math.random() - 0.5) * rand, - -dy * speed + b.vy + (Math.random() - 0.5) * rand, - rainbowColor(), - true, - gameState.coinSize / 2, - 100, - ); + makeParticle( + gameState, + a.x, + a.y, + dx * speed + a.vx + (Math.random() - 0.5) * rand, + dy * speed + a.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); + makeParticle( + gameState, + b.x, + b.y, + -dx * speed + b.vx + (Math.random() - 0.5) * rand, + -dy * speed + b.vy + (Math.random() - 0.5) * rand, + rainbowColor(), + true, + gameState.coinSize / 2, + 100, + ); } export function coinBrickHitCheck(gameState: GameState, coin: Coin) { - if(gameState.perks.ghost_coins)return undefined - // Make ball/coin bonce, and return bricks that were hit - const radius = coin.size / 2; - const {x, y, previousX, previousY} = coin; + if (gameState.perks.ghost_coins) return undefined; + // Make ball/coin bonce, and return bricks that were hit + const radius = coin.size / 2; + const { x, y, previousX, previousY } = coin; - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; - if (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 = gameState.bricks[brickIndex(x - radius, y + radius)]; - const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)]; + // Roll on corners + const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)]; + const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)]; - if (leftHit && !rightHit) { - coin.vx += 1; - coin.sa -= 1; - } - if (!leftHit && rightHit) { - coin.vx -= 1; - coin.sa += 1; - } + if (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; } export function bordersHitCheck( - gameState: GameState, - coin: Coin | Ball, - radius: number, - delta: number, + gameState: GameState, + coin: Coin | Ball, + radius: number, + delta: number, ) { - if (coin.destroyed) return; - coin.previousX = coin.x; - coin.previousY = coin.y; - coin.x += coin.vx * delta; - coin.y += coin.vy * delta; - coin.sx ||= 0; - coin.sy ||= 0; - coin.sx += coin.previousX - coin.x; - coin.sy += coin.previousY - coin.y; - coin.sx *= 0.9; - coin.sy *= 0.9; + if (coin.destroyed) return; + coin.previousX = coin.x; + coin.previousY = coin.y; + coin.x += coin.vx * delta; + coin.y += coin.vy * delta; + coin.sx ||= 0; + coin.sy ||= 0; + coin.sx += coin.previousX - coin.x; + coin.sy += coin.previousY - coin.y; + coin.sx *= 0.9; + coin.sy *= 0.9; - if (gameState.perks.wind) { - coin.vx += - ((gameState.puckPosition - - (gameState.offsetX + gameState.gameZoneWidth / 2)) / - gameState.gameZoneWidth) * - gameState.perks.wind * - 0.5; - } + if (gameState.perks.wind) { + coin.vx += + ((gameState.puckPosition - + (gameState.offsetX + gameState.gameZoneWidth / 2)) / + gameState.gameZoneWidth) * + gameState.perks.wind * + 0.5; + } - let vhit = 0, - hhit = 0; + let vhit = 0, + hhit = 0; - if (coin.x < gameState.offsetXRoundedDown + radius && !gameState.perks.unbounded) { - coin.x = - gameState.offsetXRoundedDown + - radius + - (gameState.offsetXRoundedDown + radius - coin.x); - coin.vx *= -1; - hhit = 1; - } - if (coin.y < radius) { - coin.y = radius + (radius - coin.y); - coin.vy *= -1; - vhit = 1; - } - if (coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius && !gameState.perks.unbounded) { - coin.x = - gameState.canvasWidth - - gameState.offsetXRoundedDown - - radius - - (coin.x - - (gameState.canvasWidth - gameState.offsetXRoundedDown - radius)); - coin.vx *= -1; - hhit = 1; - } + if ( + coin.x < gameState.offsetXRoundedDown + radius && + !gameState.perks.unbounded + ) { + coin.x = + gameState.offsetXRoundedDown + + radius + + (gameState.offsetXRoundedDown + radius - coin.x); + coin.vx *= -1; + hhit = 1; + } + if (coin.y < radius) { + coin.y = radius + (radius - coin.y); + coin.vy *= -1; + vhit = 1; + } + if ( + coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius && + !gameState.perks.unbounded + ) { + coin.x = + gameState.canvasWidth - + gameState.offsetXRoundedDown - + radius - + (coin.x - + (gameState.canvasWidth - gameState.offsetXRoundedDown - radius)); + coin.vx *= -1; + hhit = 1; + } - return hhit + vhit * 2; + return hhit + vhit * 2; } export function gameStateTick( - gameState: GameState, - // How many frames to compute at once, can go above 1 to compensate lag - frames = 1, + gameState: GameState, + // How many frames to compute at once, can go above 1 to compensate lag + frames = 1, ) { - gameState.runStatistics.max_combo = Math.max( - gameState.runStatistics.max_combo, - gameState.combo, + gameState.runStatistics.max_combo = Math.max( + gameState.runStatistics.max_combo, + gameState.combo, + ); + + gameState.balls = gameState.balls.filter((ball) => !ball.destroyed); + + const remainingBricks = gameState.bricks.filter( + (b) => b && b !== "black", + ).length; + + if ( + gameState.levelTime > gameState.lastTickDown + 1000 && + gameState.perks.hot_start + ) { + gameState.lastTickDown = gameState.levelTime; + decreaseCombo( + gameState, + gameState.perks.hot_start, + gameState.puckPosition, + gameState.gameZoneHeight - 2 * gameState.puckHeight, ); + } - gameState.balls = gameState.balls.filter((ball) => !ball.destroyed); + if ( + remainingBricks <= gameState.perks.skip_last && + !gameState.autoCleanUses + ) { + gameState.bricks.forEach((type, index) => { + if (type) { + explodeBrick(gameState, index, gameState.balls[0], true); + } + }); + gameState.autoCleanUses++; + } + if (!remainingBricks && gameState.noBricksSince == 0) { + gameState.noBricksSince ||= gameState.levelTime; + } + if ( + !remainingBricks && + (!liveCount(gameState.coins) || + gameState.levelTime > gameState.noBricksSince + 5000) + ) { + if (gameState.currentLevel + 1 < max_levels(gameState)) { + setLevel(gameState, gameState.currentLevel + 1); + } else { + gameOver( + t("gameOver.win.title"), + t("gameOver.win.summary", { score: gameState.score }), + ); + } + } else if (gameState.running || gameState.levelTime) { + const coinRadius = Math.round(gameState.coinSize / 2); - const remainingBricks = gameState.bricks.filter( - (b) => b && b !== "black", - ).length; + forEachLiveOne(gameState.coins, (coin, coinIndex) => { + if (gameState.perks.coin_magnet) { + const strength = + (100 / + (100 + + Math.pow(coin.y - gameState.gameZoneHeight, 2) + + Math.pow(coin.x - gameState.puckPosition, 2))) * + gameState.perks.coin_magnet; - if ( - gameState.levelTime > gameState.lastTickDown + 1000 && - gameState.perks.hot_start - ) { - gameState.lastTickDown = gameState.levelTime; - decreaseCombo( + const attractionX = + frames * (gameState.puckPosition - coin.x) * strength; + + coin.vx += attractionX; + coin.vy += + (frames * (gameState.gameZoneHeight - coin.y) * strength) / 2; + coin.sa -= attractionX / 10; + } + + if (gameState.perks.ball_attracts_coins) { + gameState.balls.forEach((ball) => { + const d2 = distance2(ball, coin); + coin.vx += + ((ball.x - coin.x) / d2) * 30 * gameState.perks.ball_attracts_coins; + coin.vy += + ((ball.y - coin.y) / d2) * 30 * gameState.perks.ball_attracts_coins; + }); + } + + const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames; + + coin.vy *= ratio; + coin.vx *= ratio; + if (coin.vx > 7 * gameState.baseSpeed) coin.vx = 7 * gameState.baseSpeed; + if (coin.vx < -7 * gameState.baseSpeed) + coin.vx = -7 * gameState.baseSpeed; + if (coin.vy > 7 * gameState.baseSpeed) coin.vy = 7 * gameState.baseSpeed; + if (coin.vy < -7 * gameState.baseSpeed) + coin.vy = -7 * gameState.baseSpeed; + coin.a += coin.sa; + + // Gravity + if (!gameState.perks.etherealcoins) { + const flip = + gameState.perks.helium > 0 && + Math.abs(coin.x - gameState.puckPosition) * 2 > + gameState.puckWidth + coin.size; + coin.vy += frames * coin.weight * 0.8 * (flip ? -1 : 1); + if (flip && !isOptionOn("basic") && Math.random() < 0.1) { + makeParticle( gameState, - gameState.perks.hot_start, - gameState.puckPosition, - gameState.gameZoneHeight - 2 * gameState.puckHeight, - ); - } - - if ( - remainingBricks <= gameState.perks.skip_last && - !gameState.autoCleanUses - ) { - gameState.bricks.forEach((type, index) => { - if (type) { - explodeBrick(gameState, index, gameState.balls[0], true); - } - }); - gameState.autoCleanUses++; - } - if (!remainingBricks && gameState.noBricksSince == 0) { - gameState.noBricksSince ||= gameState.levelTime - } - if (!remainingBricks && (!liveCount(gameState.coins) || (gameState.levelTime > gameState.noBricksSince + 5000))) { - if (gameState.currentLevel + 1 < max_levels(gameState)) { - setLevel(gameState, gameState.currentLevel + 1); - } else { - gameOver( - t("gameOver.win.title"), - t("gameOver.win.summary", {score: gameState.score}), - ); + coin.x, + coin.y, + 0, + gameState.baseSpeed, + rainbowColor(), + true, + 5, + 250, + ); } - } else if (gameState.running || gameState.levelTime) { - const coinRadius = Math.round(gameState.coinSize / 2); + } - forEachLiveOne(gameState.coins, (coin, coinIndex) => { - if (gameState.perks.coin_magnet) { - const strength = 100 / (100 + - Math.pow(coin.y - gameState.gameZoneHeight, 2) + - Math.pow(coin.x - gameState.puckPosition, 2)) * - gameState.perks.coin_magnet; - - const attractionX = frames * (gameState.puckPosition - coin.x) * strength - - coin.vx += attractionX; - coin.vy += frames * (gameState.gameZoneHeight - coin.y) * strength / 2 - coin.sa -= attractionX / 10; - - } - - if(gameState.perks.ball_attracts_coins){ - gameState.balls.forEach(ball=>{ - const d2= distance2(ball, coin) - coin.vx+=(ball.x-coin.x)/d2*30*gameState.perks.ball_attracts_coins - coin.vy+=(ball.y-coin.y)/d2*30*gameState.perks.ball_attracts_coins - }) - } - - const ratio = 1 - (gameState.perks.viscosity * 0.03 + 0.005) * frames; - - coin.vy *= ratio; - coin.vx *= ratio; - if (coin.vx > 7 * gameState.baseSpeed) coin.vx = 7 * gameState.baseSpeed; - if (coin.vx < -7 * gameState.baseSpeed) - coin.vx = -7 * gameState.baseSpeed; - if (coin.vy > 7 * gameState.baseSpeed) coin.vy = 7 * gameState.baseSpeed; - if (coin.vy < -7 * gameState.baseSpeed) - coin.vy = -7 * gameState.baseSpeed; - coin.a += coin.sa; - - // Gravity - if (!gameState.perks.etherealcoins) { - const flip = gameState.perks.helium > 0 && Math.abs(coin.x - gameState.puckPosition) * 2 > gameState.puckWidth + coin.size - coin.vy += frames * coin.weight * 0.8 * (flip ? -1 : 1); - if (flip && !isOptionOn('basic') && Math.random() < 0.1) { - makeParticle(gameState, - coin.x, - coin.y, - 0, - gameState.baseSpeed, - rainbowColor(), - true, - 5, - 250 - ) - } - - } - - const speed = Math.abs(coin.sx) + Math.abs(coin.sx); - const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); - - if ( - coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight && - coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy && - Math.abs(coin.x - gameState.puckPosition) < - coinRadius + - gameState.puckWidth / 2 + // a bit of margin to be nice - gameState.puckHeight - ) { - addToScore(gameState, coin); - destroy(gameState.coins, coinIndex); - } else if (coin.y > gameState.canvasHeight + coinRadius) { - destroy(gameState.coins, coinIndex); - if (gameState.perks.compound_interest) { - resetCombo(gameState, coin.x, coin.y); - } - } else if (gameState.perks.unbounded && (coin.x < -50 || coin.x > gameState.canvasWidth + 50)) { - // Out of bound on sides - destroy(gameState.coins, coinIndex); - } - - - const hitBrick = coinBrickHitCheck(gameState, coin); - - if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { - if ( - gameState.bricks[hitBrick] && - coin.color !== gameState.bricks[hitBrick] && - gameState.bricks[hitBrick] !== "black" && - !coin.coloredABrick - ) { - gameState.bricks[hitBrick] = coin.color; - coin.coloredABrick = true; - - schedulGameSound(gameState, "colorChange", coin.x, 0.3); - } - } - if (typeof hitBrick !== "undefined" || hitBorder) { - coin.vx *= 0.8; - coin.vy *= 0.8; - coin.sa *= 0.9; - if (speed > 20) { - schedulGameSound(gameState, "coinBounce", coin.x, 0.2); - } - - if (Math.abs(coin.vy) < 3) { - coin.vy = 0; - } - } - }); - - gameState.balls.forEach((ball) => ballTick(gameState, ball, frames)); - - if (gameState.perks.shocks) { - gameState.balls.forEach((a, ai) => - gameState.balls.forEach((b, bi) => { - if (ai < bi && !a.destroyed && !b.destroyed && distance2(a, b) < gameState.ballSize * gameState.ballSize) { - let tempVx = a.vx - let tempVy = a.vy - a.vx = b.vx - a.vy = b.vy - b.vx = tempVx - b.vy = tempVy - - let x = (a.x + b.x) / 2 - let y = (a.y + b.y) / 2 - const limit = gameState.baseSpeed - a.vx += clamp(a.x - x, -limit, limit)+(Math.random()-0.5) * limit/3 - a.vy += clamp(a.y - y, -limit, limit)+(Math.random()-0.5) * limit/3 - b.vx += clamp(b.x - x, -limit, limit)+(Math.random()-0.5) * limit/3 - b.vy += clamp(b.y - y, -limit, limit)+(Math.random()-0.5) * limit/3 - - let index = brickIndex(x, y) - - explosionAt(gameState, index, x, y, a) - } - } - ) - ) - } - - if (gameState.perks.wind) { - const windD = - ((gameState.puckPosition - - (gameState.offsetX + gameState.gameZoneWidth / 2)) / - gameState.gameZoneWidth) * - 2 * - gameState.perks.wind; - for (let i = 0; i < gameState.perks.wind; i++) { - if (Math.random() * Math.abs(windD) > 0.5) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - Math.random() * gameState.gameZoneHeight, - windD * 8, - 0, - rainbowColor(), - true, - gameState.coinSize / 2, - 150, - ); - } - } - } - forEachLiveOne(gameState.particles, (flash, index) => { - flash.x += flash.vx * frames; - flash.y += flash.vy * frames; - if (!flash.ethereal) { - flash.vy += 0.5; - if (hasBrick(brickIndex(flash.x, flash.y))) { - destroy(gameState.particles, index); - } - } - }); - } - - if ( - gameState.combo > baseCombo(gameState) && - !isOptionOn("basic") && - (gameState.combo - baseCombo(gameState)) * Math.random() > 5 - ) { - // The red should still be visible on a white bg - - if (gameState.perks.top_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + - Math.random() * gameState.gameZoneWidthRoundedUp, - 0, - (Math.random() - 0.5) * 10, - 5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } - - if (gameState.perks.left_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown, - Math.random() * gameState.gameZoneHeight, - 5, - (Math.random() - 0.5) * 10, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } - - if (gameState.perks.right_is_lava) { - makeParticle( - gameState, - gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, - Math.random() * gameState.gameZoneHeight, - -5, - (Math.random() - 0.5) * 10, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); - } + const speed = Math.abs(coin.sx) + Math.abs(coin.sx); + const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); + if ( + coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight && + coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy && + Math.abs(coin.x - gameState.puckPosition) < + coinRadius + + gameState.puckWidth / 2 + // a bit of margin to be nice + gameState.puckHeight + ) { + addToScore(gameState, coin); + destroy(gameState.coins, coinIndex); + } else if (coin.y > gameState.canvasHeight + coinRadius) { + destroy(gameState.coins, coinIndex); if (gameState.perks.compound_interest) { - let x = gameState.puckPosition, - attemps = 0; - do { - x = - gameState.offsetXRoundedDown + - gameState.gameZoneWidthRoundedUp * Math.random(); - attemps++; - } while ( - Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 && - attemps < 10 - ); + resetCombo(gameState, coin.x, coin.y); + } + } else if ( + gameState.perks.unbounded && + (coin.x < -50 || coin.x > gameState.canvasWidth + 50) + ) { + // Out of bound on sides + destroy(gameState.coins, coinIndex); + } - makeParticle( - gameState, - x, - gameState.gameZoneHeight, - (Math.random() - 0.5) * 10, - -5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); + const hitBrick = coinBrickHitCheck(gameState, coin); + + if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") { + if ( + gameState.bricks[hitBrick] && + coin.color !== gameState.bricks[hitBrick] && + gameState.bricks[hitBrick] !== "black" && + !coin.coloredABrick + ) { + gameState.bricks[hitBrick] = coin.color; + coin.coloredABrick = true; + + schedulGameSound(gameState, "colorChange", coin.x, 0.3); } - if (gameState.perks.streak_shots) { - const pos = 0.5 - Math.random(); - makeParticle( - gameState, - gameState.puckPosition + gameState.puckWidth * pos, - gameState.gameZoneHeight - gameState.puckHeight, - pos * 10, - -5, - "red", - true, - gameState.coinSize / 2, - 100 * (Math.random() + 1), - ); + } + if (typeof hitBrick !== "undefined" || hitBorder) { + coin.vx *= 0.8; + coin.vy *= 0.8; + coin.sa *= 0.9; + if (speed > 20) { + schedulGameSound(gameState, "coinBounce", coin.x, 0.2); } + + if (Math.abs(coin.vy) < 3) { + coin.vy = 0; + } + } + }); + + gameState.balls.forEach((ball) => ballTick(gameState, ball, frames)); + + if (gameState.perks.shocks) { + gameState.balls.forEach((a, ai) => + gameState.balls.forEach((b, bi) => { + if ( + ai < bi && + !a.destroyed && + !b.destroyed && + distance2(a, b) < gameState.ballSize * gameState.ballSize + ) { + let tempVx = a.vx; + let tempVy = a.vy; + a.vx = b.vx; + a.vy = b.vy; + b.vx = tempVx; + b.vy = tempVy; + + let x = (a.x + b.x) / 2; + let y = (a.y + b.y) / 2; + const limit = gameState.baseSpeed; + a.vx += + clamp(a.x - x, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + a.vy += + clamp(a.y - y, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + b.vx += + clamp(b.x - x, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + b.vy += + clamp(b.y - y, -limit, limit) + + ((Math.random() - 0.5) * limit) / 3; + + let index = brickIndex(x, y); + + explosionAt(gameState, index, x, y, a); + } + }), + ); } - forEachLiveOne(gameState.particles, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.particles, pi); + if (gameState.perks.wind) { + const windD = + ((gameState.puckPosition - + (gameState.offsetX + gameState.gameZoneWidth / 2)) / + gameState.gameZoneWidth) * + 2 * + gameState.perks.wind; + for (let i = 0; i < gameState.perks.wind; i++) { + if (Math.random() * Math.abs(windD) > 0.5) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + Math.random() * gameState.gameZoneHeight, + windD * 8, + 0, + rainbowColor(), + true, + gameState.coinSize / 2, + 150, + ); } - }); - forEachLiveOne(gameState.texts, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.texts, pi); - } - }); - forEachLiveOne(gameState.lights, (p, pi) => { - if (gameState.levelTime > p.time + p.duration) { - destroy(gameState.lights, pi); + } + } + forEachLiveOne(gameState.particles, (flash, index) => { + flash.x += flash.vx * frames; + flash.y += flash.vy * frames; + if (!flash.ethereal) { + flash.vy += 0.5; + if (hasBrick(brickIndex(flash.x, flash.y))) { + destroy(gameState.particles, index); } + } }); + } + + if ( + gameState.combo > baseCombo(gameState) && + !isOptionOn("basic") && + (gameState.combo - baseCombo(gameState)) * Math.random() > 5 + ) { + // The red should still be visible on a white bg + + if (gameState.perks.top_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + + Math.random() * gameState.gameZoneWidthRoundedUp, + 0, + (Math.random() - 0.5) * 10, + 5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.left_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown, + Math.random() * gameState.gameZoneHeight, + 5, + (Math.random() - 0.5) * 10, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.right_is_lava) { + makeParticle( + gameState, + gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp, + Math.random() * gameState.gameZoneHeight, + -5, + (Math.random() - 0.5) * 10, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + + if (gameState.perks.compound_interest) { + let x = gameState.puckPosition, + attemps = 0; + do { + x = + gameState.offsetXRoundedDown + + gameState.gameZoneWidthRoundedUp * Math.random(); + attemps++; + } while ( + Math.abs(x - gameState.puckPosition) < gameState.puckWidth / 2 && + attemps < 10 + ); + + makeParticle( + gameState, + x, + gameState.gameZoneHeight, + (Math.random() - 0.5) * 10, + -5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + if (gameState.perks.streak_shots) { + const pos = 0.5 - Math.random(); + makeParticle( + gameState, + gameState.puckPosition + gameState.puckWidth * pos, + gameState.gameZoneHeight - gameState.puckHeight, + pos * 10, + -5, + "red", + true, + gameState.coinSize / 2, + 100 * (Math.random() + 1), + ); + } + } + + forEachLiveOne(gameState.particles, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.particles, pi); + } + }); + forEachLiveOne(gameState.texts, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.texts, pi); + } + }); + forEachLiveOne(gameState.lights, (p, pi) => { + if (gameState.levelTime > p.time + p.duration) { + destroy(gameState.lights, pi); + } + }); } export function ballTick(gameState: GameState, ball: Ball, delta: number) { - ball.previousVX = ball.vx; - ball.previousVY = ball.vy; + ball.previousVX = ball.vx; + ball.previousVY = ball.vy; - let speedLimitDampener = - 1 + - gameState.perks.telekinesis + - gameState.perks.ball_repulse_ball + - gameState.perks.puck_repulse_ball + - gameState.perks.ball_attract_ball; + let speedLimitDampener = + 1 + + gameState.perks.telekinesis + + gameState.perks.ball_repulse_ball + + gameState.perks.puck_repulse_ball + + gameState.perks.ball_attract_ball; - if (isTelekinesisActive(gameState, ball)) { - speedLimitDampener += 3; - ball.vx += - ((gameState.puckPosition - ball.x) / 1000) * - delta * - gameState.perks.telekinesis; - } - if (isYoyoActive(gameState, ball)) { - speedLimitDampener += 3; - ball.vx += - ((gameState.puckPosition - ball.x) / 1000) * - delta * - gameState.perks.yoyo; - } + if (isTelekinesisActive(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += + ((gameState.puckPosition - ball.x) / 1000) * + delta * + gameState.perks.telekinesis; + } + if (isYoyoActive(gameState, ball)) { + speedLimitDampener += 3; + ball.vx += + ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo; + } - if ( - ball.vx * ball.vx + ball.vy * ball.vy < - gameState.baseSpeed * gameState.baseSpeed * 2 - ) { - ball.vx *= 1 + 0.02 / speedLimitDampener; - ball.vy *= 1 + 0.02 / speedLimitDampener; - } else { - ball.vx *= 1 - 0.02 / speedLimitDampener; - ball.vy *= 1 - 0.02 / speedLimitDampener; - } - // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract - if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) { - ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; - } + if ( + ball.vx * ball.vx + ball.vy * ball.vy < + gameState.baseSpeed * gameState.baseSpeed * 2 + ) { + ball.vx *= 1 + 0.02 / speedLimitDampener; + ball.vy *= 1 + 0.02 / speedLimitDampener; + } else { + ball.vx *= 1 - 0.02 / speedLimitDampener; + ball.vy *= 1 - 0.02 / speedLimitDampener; + } + // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract + if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) { + ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; + } - if (gameState.perks.ball_repulse_ball) { - for (let b2 of gameState.balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true); - } + if (gameState.perks.ball_repulse_ball) { + for (let b2 of gameState.balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true); } - if (gameState.perks.ball_attract_ball) { - for (let b2 of gameState.balls) { - // avoid computing this twice, and repulsing itself - if (b2.x >= ball.x) continue; - attract(gameState, ball, b2, gameState.perks.ball_attract_ball); - } + } + if (gameState.perks.ball_attract_ball) { + for (let b2 of gameState.balls) { + // avoid computing this twice, and repulsing itself + if (b2.x >= ball.x) continue; + attract(gameState, ball, b2, gameState.perks.ball_attract_ball); } - if ( - gameState.perks.puck_repulse_ball && - Math.abs(ball.x - gameState.puckPosition) < - gameState.puckWidth / 2 + + } + if ( + gameState.perks.puck_repulse_ball && + Math.abs(ball.x - gameState.puckPosition) < + gameState.puckWidth / 2 + (gameState.ballSize * (9 + gameState.perks.puck_repulse_ball)) / 10 + ) { + repulse( + gameState, + ball, + { + x: gameState.puckPosition, + y: gameState.gameZoneHeight, + }, + gameState.perks.puck_repulse_ball + 1, + false, + ); + } + + if ( + gameState.perks.respawn && + ball.hitItem?.length > 1 && + !isOptionOn("basic") + ) { + for ( + let i = 0; + i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; + i++ ) { - repulse( - gameState, - ball, - { - x: gameState.puckPosition, - y: gameState.gameZoneHeight, - }, - gameState.perks.puck_repulse_ball + 1, - false, - ); + const { index, color } = ball.hitItem[i]; + if (gameState.bricks[index] || color === "black") continue; + const vertical = Math.random() > 0.5; + const dx = Math.random() > 0.5 ? 1 : -1; + const dy = Math.random() > 0.5 ? 1 : -1; + + makeParticle( + gameState, + brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2, + brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2, + vertical ? 0 : -dx * gameState.baseSpeed, + vertical ? -dy * gameState.baseSpeed : 0, + color, + true, + gameState.coinSize / 2, + 250, + ); + } + } + + const borderHitCode = bordersHitCheck( + gameState, + ball, + gameState.ballSize / 2, + delta, + ); + if (borderHitCode) { + if ( + gameState.perks.left_is_lava && + borderHitCode % 2 && + ball.x < gameState.offsetX + gameState.gameZoneWidth / 2 + ) { + resetCombo(gameState, ball.x, ball.y); } if ( - gameState.perks.respawn && - ball.hitItem?.length > 1 && - !isOptionOn("basic") + gameState.perks.right_is_lava && + borderHitCode % 2 && + ball.x > gameState.offsetX + gameState.gameZoneWidth / 2 ) { - for ( - let i = 0; - i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; - i++ - ) { - const {index, color} = ball.hitItem[i]; - if (gameState.bricks[index] || color === "black") continue; - const vertical = Math.random() > 0.5; - const dx = Math.random() > 0.5 ? 1 : -1; - const dy = Math.random() > 0.5 ? 1 : -1; - - makeParticle( - gameState, - brickCenterX(gameState, index) + (dx * gameState.brickWidth) / 2, - brickCenterY(gameState, index) + (dy * gameState.brickWidth) / 2, - vertical ? 0 : -dx * gameState.baseSpeed, - vertical ? -dy * gameState.baseSpeed : 0, - color, - true, - gameState.coinSize / 2, - 250, - ); - } + resetCombo(gameState, ball.x, ball.y); } - const borderHitCode = bordersHitCheck(gameState, ball, gameState.ballSize / 2, delta); - if (borderHitCode) { - if ( - gameState.perks.left_is_lava && - borderHitCode % 2 && - ball.x < gameState.offsetX + gameState.gameZoneWidth / 2 - ) { - resetCombo(gameState, ball.x, ball.y); - } - - if ( - gameState.perks.right_is_lava && - borderHitCode % 2 && - ball.x > gameState.offsetX + gameState.gameZoneWidth / 2 - ) { - resetCombo(gameState, ball.x, ball.y); - } - - if (gameState.perks.top_is_lava && borderHitCode >= 2) { - resetCombo(gameState, ball.x, ball.y + gameState.ballSize); - } - - schedulGameSound(gameState, "wallBeep", ball.x, 1); - gameState.levelWallBounces++; - gameState.runStatistics.wall_bounces++; + if (gameState.perks.top_is_lava && borderHitCode >= 2) { + resetCombo(gameState, ball.x, ball.y + gameState.ballSize); } - // Puck collision - const ylimit = - gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2; - const ballIsUnderPuck = - Math.abs(ball.x - gameState.puckPosition) < - gameState.ballSize / 2 + gameState.puckWidth / 2; - if ( - ball.y > ylimit && - ball.vy > 0 && - (ballIsUnderPuck || - (gameState.perks.extra_life && - ball.y > ylimit + gameState.puckHeight / 2)) - ) { - if (ballIsUnderPuck) { - const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); - const angle = Math.atan2( - -gameState.puckWidth / 2, - (ball.x - gameState.puckPosition) * - (gameState.perks.concave_puck ? -0.5 : 1), - ); - ball.vx = speed * Math.cos(angle); - ball.vy = speed * Math.sin(angle); - schedulGameSound(gameState, "wallBeep", ball.x, 1); + schedulGameSound(gameState, "wallBeep", ball.x, 1); + gameState.levelWallBounces++; + gameState.runStatistics.wall_bounces++; + } + + // Puck collision + const ylimit = + gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2; + const ballIsUnderPuck = + Math.abs(ball.x - gameState.puckPosition) < + gameState.ballSize / 2 + gameState.puckWidth / 2; + if ( + ball.y > ylimit && + ball.vy > 0 && + (ballIsUnderPuck || + (gameState.perks.extra_life && + ball.y > ylimit + gameState.puckHeight / 2)) + ) { + if (ballIsUnderPuck) { + const speed = Math.sqrt(ball.vx * ball.vx + ball.vy * ball.vy); + const angle = Math.atan2( + -gameState.puckWidth / 2, + (ball.x - gameState.puckPosition) * + (gameState.perks.concave_puck ? -0.5 : 1), + ); + ball.vx = speed * Math.cos(angle); + ball.vy = speed * Math.sin(angle); + schedulGameSound(gameState, "wallBeep", ball.x, 1); + } else { + ball.vy *= -1; + + gameState.perks.extra_life -= 1; + if (gameState.perks.extra_life < 0) { + gameState.perks.extra_life = 0; + } else if (gameState.perks.sacrifice) { + if (liveCount(gameState.coins) < gameState.MAX_COINS / 2) { + // true duplication + let remaining = liveCount(gameState.coins); + + forEachLiveOne(gameState.coins, (source, index) => { + if (!remaining) return; + append(gameState.coins, (copy) => { + copy.points = source.points; + copy.color = source.color; + copy.x = source.x; + copy.y = source.y; + copy.size = source.size; + copy.previousX = source.previousX; + copy.previousY = source.previousY; + copy.vx = -source.vx; + copy.vy = -source.vy; + copy.sx = source.sx; + copy.sy = source.sy; + copy.a = source.a; + copy.sa = -source.sa; + copy.weight = source.weight; + copy.coloredABrick = source.coloredABrick; + }); + remaining--; + }); } else { - ball.vy *= -1; - - gameState.perks.extra_life -= 1 - if(gameState.perks.extra_life <0){ - gameState.perks.extra_life=0 - }else if(gameState.perks.sacrifice){ - if(liveCount(gameState.coins){ - if(!remaining) return - append(gameState.coins, copy=>{ - copy.points=source.points - copy.color=source.color - copy.x=source.x - copy.y=source.y - copy.size=source.size - copy.previousX=source.previousX - copy.previousY=source.previousY - copy.vx=-source.vx - copy.vy=-source.vy - copy.sx=source.sx - copy.sy=source.sy - copy.a=source.a - copy.sa=-source.sa - copy.weight=source.weight - copy.coloredABrick=source.coloredABrick - }) - remaining-- - }) - }else{ - forEachLiveOne(gameState.coins, (source, index)=>{ - source.points*=2 - }) - // spawn a few coins for effect, but mostly increment poitns counter - } - } - - - - - schedulGameSound(gameState, "lifeLost", ball.x, 1); - if (!isOptionOn("basic")) { - for (let i = 0; i < 10; i++) - makeParticle( - gameState, - ball.x, - ball.y, - Math.random() * gameState.baseSpeed * 3, - gameState.baseSpeed * 3, - "red", - false, - gameState.coinSize / 2, - 150, - ); - } + forEachLiveOne(gameState.coins, (source, index) => { + source.points *= 2; + }); + // spawn a few coins for effect, but mostly increment poitns counter } - if (gameState.perks.streak_shots) { - resetCombo(gameState, ball.x, ball.y); - } - if (gameState.perks.trampoline) { - gameState.combo+=gameState.perks.trampoline - } - if (gameState.perks.nbricks) { - if (ball.hitSinceBounce) { - if (gameState.perks.nbricks === ball.hitSinceBounce) { - gameState.combo += gameState.perks.nbricks - } else { - resetCombo(gameState, ball.x, ball.y) - } + } - } + schedulGameSound(gameState, "lifeLost", ball.x, 1); + if (!isOptionOn("basic")) { + for (let i = 0; i < 10; i++) + makeParticle( + gameState, + ball.x, + ball.y, + Math.random() * gameState.baseSpeed * 3, + gameState.baseSpeed * 3, + "red", + false, + gameState.coinSize / 2, + 150, + ); + } + } + if (gameState.perks.streak_shots) { + resetCombo(gameState, ball.x, ball.y); + } + if (gameState.perks.trampoline) { + gameState.combo += gameState.perks.trampoline; + } + if (gameState.perks.nbricks) { + if (ball.hitSinceBounce) { + if (gameState.perks.nbricks === ball.hitSinceBounce) { + gameState.combo += gameState.perks.nbricks; + } else { + resetCombo(gameState, ball.x, ball.y); } - - if (gameState.perks.respawn) { - ball.hitItem - .slice(0, -1) - .slice(0, gameState.perks.respawn) - .forEach(({index, color}) => { - if (!gameState.bricks[index] && color !== "black") - gameState.bricks[index] = color; - }); - } - ball.hitItem = []; - if (!ball.hitSinceBounce) { - gameState.runStatistics.misses++; - gameState.levelMisses++; - if(gameState.perks.forgiving){ - const indexes = gameState.bricks.map((b,i)=>b ? i:-1) - .filter(i=>i>-1) - const pick = sample(indexes) - explodeBrick(gameState,pick, ball, false) - }else{ - resetCombo(gameState, ball.x, ball.y); - } - makeText( - gameState, - gameState.puckPosition, - gameState.gameZoneHeight - gameState.puckHeight * 2, - "red", - t("play.missed_ball"), - gameState.puckHeight, - 500, - ); - } - gameState.runStatistics.puck_bounces++; - ball.hitSinceBounce = 0; - ball.sapperUses = 0; - ball.piercedSinceBounce = 0; + } } - const lostOnSides = gameState.perks.unbounded && ball.x < 50 || ball.x > gameState.canvasWidth + 50 - if (gameState.running && - (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides) + if (gameState.perks.respawn) { + ball.hitItem + .slice(0, -1) + .slice(0, gameState.perks.respawn) + .forEach(({ index, color }) => { + if (!gameState.bricks[index] && color !== "black") + gameState.bricks[index] = color; + }); + } + ball.hitItem = []; + if (!ball.hitSinceBounce) { + gameState.runStatistics.misses++; + gameState.levelMisses++; + if (gameState.perks.forgiving) { + const indexes = gameState.bricks + .map((b, i) => (b ? i : -1)) + .filter((i) => i > -1); + const pick = sample(indexes); + explodeBrick(gameState, pick, ball, false); + } else { + resetCombo(gameState, ball.x, ball.y); + } + makeText( + gameState, + gameState.puckPosition, + gameState.gameZoneHeight - gameState.puckHeight * 2, + "red", + t("play.missed_ball"), + gameState.puckHeight, + 500, + ); + } + gameState.runStatistics.puck_bounces++; + ball.hitSinceBounce = 0; + ball.sapperUses = 0; + ball.piercedSinceBounce = 0; + } + const lostOnSides = + (gameState.perks.unbounded && ball.x < 50) || + ball.x > gameState.canvasWidth + 50; + if ( + gameState.running && + (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides) + ) { + ball.destroyed = true; + gameState.runStatistics.balls_lost++; + if (!gameState.balls.find((b) => !b.destroyed)) { + gameOver( + t("gameOver.lost.title"), + t("gameOver.lost.summary", { score: gameState.score }), + ); + } + } + const radius = gameState.ballSize / 2; + // Make ball/coin bonce, and return bricks that were hit + const { x, y, previousX, previousY } = ball; + + const vhit = hitsSomething(previousX, y, radius); + const hhit = hitsSomething(x, previousY, radius); + const chit = + (typeof vhit == "undefined" && + typeof hhit == "undefined" && + hitsSomething(x, y, radius)) || + undefined; + + const hitBrick = vhit ?? hhit ?? chit; + let sturdyBounce = + hitBrick && + gameState.bricks[hitBrick] !== "black" && + gameState.perks.sturdy_bricks && + gameState.perks.sturdy_bricks > Math.random() * 5; + + let pierce = false; + if (sturdyBounce || typeof hitBrick === "undefined") { + // cannot pierce + } else if (shouldPierceByColor(gameState, vhit, hhit, chit)) { + pierce = true; + } else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { + pierce = true; + ball.piercedSinceBounce++; + } + + if (typeof vhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.y = ball.previousY; + ball.vy *= -1; + } + } + if (typeof hhit !== "undefined" || typeof chit !== "undefined") { + if (!pierce) { + ball.x = ball.previousX; + ball.vx *= -1; + } + } + + if (sturdyBounce) { + schedulGameSound(gameState, "wallBeep", x, 1); + return; + } + if (typeof hitBrick !== "undefined") { + const initialBrickColor = gameState.bricks[hitBrick]; + + ball.hitSinceBounce++; + explodeBrick(gameState, hitBrick, ball, false); + + if ( + ball.sapperUses < gameState.perks.sapper && + initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks + !gameState.bricks[hitBrick] ) { - ball.destroyed = true; - gameState.runStatistics.balls_lost++; - if (!gameState.balls.find((b) => !b.destroyed)) { - gameOver( - t("gameOver.lost.title"), - t("gameOver.lost.summary", {score: gameState.score}), - ); - } + gameState.bricks[hitBrick] = "black"; + ball.sapperUses++; } - const radius = gameState.ballSize / 2; - // Make ball/coin bonce, and return bricks that were hit - const {x, y, previousX, previousY} = ball; + } - const vhit = hitsSomething(previousX, y, radius); - const hhit = hitsSomething(x, previousY, radius); - const chit = - (typeof vhit == "undefined" && - typeof hhit == "undefined" && - hitsSomething(x, y, radius)) || - undefined; + if (!isOptionOn("basic")) { + const remainingPierce = + gameState.perks.pierce * 3 - ball.piercedSinceBounce; + const remainingSapper = ball.sapperUses < gameState.perks.sapper; + const extraCombo = gameState.combo - 1; + if ( + (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) || + (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) || + (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) + ) { + const color = remainingSapper + ? Math.random() > 0.5 + ? "orange" + : "red" + : gameState.ballsColor; - const hitBrick = vhit ?? hhit ?? chit; - let sturdyBounce = - hitBrick && - gameState.bricks[hitBrick] !== "black" && - gameState.perks.sturdy_bricks && - gameState.perks.sturdy_bricks > Math.random() * 5; - - let pierce = false; - if (sturdyBounce || typeof hitBrick === "undefined") { - // cannot pierce - } else if (shouldPierceByColor(gameState, vhit, hhit, chit)) { - pierce = true; - } else if (ball.piercedSinceBounce < gameState.perks.pierce * 3) { - pierce = true; - ball.piercedSinceBounce++; - } - - if (typeof vhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.y = ball.previousY; - ball.vy *= -1; - } - } - if (typeof hhit !== "undefined" || typeof chit !== "undefined") { - if (!pierce) { - ball.x = ball.previousX; - ball.vx *= -1; - } - } - - if (sturdyBounce) { - schedulGameSound(gameState, "wallBeep", x, 1); - return; - } - if (typeof hitBrick !== "undefined") { - const initialBrickColor = gameState.bricks[hitBrick]; - - ball.hitSinceBounce++; - explodeBrick(gameState, hitBrick, ball, false); - - if ( - ball.sapperUses < gameState.perks.sapper && - initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks - !gameState.bricks[hitBrick] - ) { - gameState.bricks[hitBrick] = "black"; - ball.sapperUses++; - } - } - - if (!isOptionOn("basic")) { - const remainingPierce = - gameState.perks.pierce * 3 - ball.piercedSinceBounce; - const remainingSapper = ball.sapperUses < gameState.perks.sapper; - const extraCombo = gameState.combo - 1; - if ( - (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) || - (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) || - (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) - ) { - const color = remainingSapper - ? Math.random() > 0.5 - ? "orange" - : "red" - : gameState.ballsColor; - - makeParticle( - gameState, - ball.x, - ball.y, - gameState.perks.pierce_color || remainingPierce - ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 - : (Math.random() - 0.5) * gameState.baseSpeed, - gameState.perks.pierce_color || remainingPierce - ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 - : (Math.random() - 0.5) * gameState.baseSpeed, - color, - true, - gameState.coinSize / 2, - 100, - ); - } + makeParticle( + gameState, + ball.x, + ball.y, + gameState.perks.pierce_color || remainingPierce + ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 + : (Math.random() - 0.5) * gameState.baseSpeed, + gameState.perks.pierce_color || remainingPierce + ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 + : (Math.random() - 0.5) * gameState.baseSpeed, + color, + true, + gameState.coinSize / 2, + 100, + ); } + } } function makeCoin( - gameState: GameState, - x: number, - y: number, - vx: number, - vy: number, - color = "gold", - points = 1, + gameState: GameState, + x: number, + y: number, + vx: number, + vy: number, + color = "gold", + points = 1, ) { - append(gameState.coins, (p: Partial) => { - p.x = x; - p.y = y; - p.size = gameState.coinSize; - p.previousX = x; - p.previousY = y; - p.vx = vx; - p.vy = vy; - p.sx = 0; - p.sy = 0; - p.color = color; - p.a = Math.random() * Math.PI * 2; - p.sa = Math.random() - 0.5; - p.weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01); - p.points = points; - }); + append(gameState.coins, (p: Partial) => { + p.x = x; + p.y = y; + p.size = gameState.coinSize; + p.previousX = x; + p.previousY = y; + p.vx = vx; + p.vy = vy; + p.sx = 0; + p.sy = 0; + p.color = color; + p.a = Math.random() * Math.PI * 2; + p.sa = Math.random() - 0.5; + p.weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01); + p.points = points; + }); } function makeParticle( - gameState: GameState, - x: number, - y: number, - vx: number, - vy: number, - color: colorString, - ethereal = false, - size = 8, - duration = 150, + gameState: GameState, + x: number, + y: number, + vx: number, + vy: number, + color: colorString, + ethereal = false, + size = 8, + duration = 150, ) { - append(gameState.particles, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.vx = vx; - p.vy = vy; - p.color = color; - p.size = size; - p.duration = duration; - p.ethereal = ethereal; - }); + append(gameState.particles, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.vx = vx; + p.vy = vy; + p.color = color; + p.size = size; + p.duration = duration; + p.ethereal = ethereal; + }); } function makeText( - gameState: GameState, - x: number, - y: number, - color: colorString, - text: string, - size = 20, - duration = 150, + gameState: GameState, + x: number, + y: number, + color: colorString, + text: string, + size = 20, + duration = 150, ) { - append(gameState.texts, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.color = color; - p.size = size; - p.duration = duration; - p.text = text; - }); + append(gameState.texts, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.color = color; + p.size = size; + p.duration = duration; + p.text = text; + }); } function makeLight( - gameState: GameState, - x: number, - y: number, - color: colorString, - size = 8, - duration = 150, + gameState: GameState, + x: number, + y: number, + color: colorString, + size = 8, + duration = 150, ) { - append(gameState.lights, (p: Partial) => { - p.time = gameState.levelTime; - p.x = x; - p.y = y; - p.color = color; - p.size = size; - p.duration = duration; - }); + append(gameState.lights, (p: Partial) => { + p.time = gameState.levelTime; + p.x = x; + p.y = y; + p.color = color; + p.size = size; + p.duration = duration; + }); } export function append( - where: ReusableArray, - makeItem: (match: Partial) => void, + where: ReusableArray, + makeItem: (match: Partial) => void, ) { - while ( - where.list[where.indexMin] && - !where.list[where.indexMin].destroyed && - where.indexMin < where.list.length - ) { - where.indexMin++; - } - if (where.indexMin < where.list.length) { - where.list[where.indexMin].destroyed = false; - makeItem(where.list[where.indexMin]); - where.indexMin++; - } else { - const p = {destroyed: false}; - makeItem(p); - where.list.push(p); - } - where.total++; + while ( + where.list[where.indexMin] && + !where.list[where.indexMin].destroyed && + where.indexMin < where.list.length + ) { + where.indexMin++; + } + if (where.indexMin < where.list.length) { + where.list[where.indexMin].destroyed = false; + makeItem(where.list[where.indexMin]); + where.indexMin++; + } else { + const p = { destroyed: false }; + makeItem(p); + where.list.push(p); + } + where.total++; } export function destroy(where: ReusableArray, index: number) { - if (where.list[index].destroyed) return; - where.list[index].destroyed = true; - where.indexMin = Math.min(where.indexMin, index); - where.total--; + if (where.list[index].destroyed) return; + where.list[index].destroyed = true; + where.indexMin = Math.min(where.indexMin, index); + where.total--; } export function liveCount(where: ReusableArray) { - return where.total; + return where.total; } export function empty(where: ReusableArray) { - where.total = 0; - where.indexMin = 0; - where.list.forEach((i) => (i.destroyed = true)); + where.total = 0; + where.indexMin = 0; + where.list.forEach((i) => (i.destroyed = true)); } export function forEachLiveOne( - where: ReusableArray, - cb: (t: T, index: number) => void, + where: ReusableArray, + cb: (t: T, index: number) => void, ) { - where.list.forEach((item: T, index: number) => { - if (item && !item.destroyed) { - cb(item, index); - } - }); + where.list.forEach((item: T, index: number) => { + if (item && !item.destroyed) { + cb(item, index); + } + }); } diff --git a/src/game_utils.ts b/src/game_utils.ts index c87d256..ec07e26 100644 --- a/src/game_utils.ts +++ b/src/game_utils.ts @@ -1,5 +1,5 @@ -import {Ball, GameState, PerkId, PerksMap} from "./types"; -import {icons, upgrades} from "./loadGameData"; +import { Ball, GameState, PerkId, PerksMap } from "./types"; +import { icons, upgrades } from "./loadGameData"; export function getMajorityValue(arr: string[]): string { const count: { [k: string]: number } = {}; @@ -72,10 +72,10 @@ export function currentLevelInfo(gameState: GameState) { } export function isTelekinesisActive(gameState: GameState, ball: Ball) { - return gameState.perks.telekinesis && ball.vy < 0; + return gameState.perks.telekinesis && ball.vy < 0; } export function isYoyoActive(gameState: GameState, ball: Ball) { - return gameState.perks.yoyo && ball.vy > 0; + return gameState.perks.yoyo && ball.vy > 0; } export function findLast( @@ -103,9 +103,8 @@ export function distanceBetween( return Math.sqrt(distance2(a, b)); } - -export function clamp(value, min, max){ - return Math.max(min, Math.min(value, max)) +export function clamp(value: number, min: number, max: number) { + return Math.max(min, Math.min(value, max)); } export function defaultSounds() { return { @@ -123,29 +122,29 @@ export function defaultSounds() { } export function shouldPierceByColor( - gameState: GameState, - vhit: number | undefined, - hhit: number | undefined, - chit: number | undefined, + gameState: GameState, + vhit: number | undefined, + hhit: number | undefined, + chit: number | undefined, ) { - if (!gameState.perks.pierce_color) return false; - if ( - typeof vhit !== "undefined" && - gameState.bricks[vhit] !== gameState.ballsColor - ) { - return false; - } - if ( - typeof hhit !== "undefined" && - gameState.bricks[hhit] !== gameState.ballsColor - ) { - return false; - } - if ( - typeof chit !== "undefined" && - gameState.bricks[chit] !== gameState.ballsColor - ) { - return false; - } - return true; -} \ No newline at end of file + if (!gameState.perks.pierce_color) return false; + if ( + typeof vhit !== "undefined" && + gameState.bricks[vhit] !== gameState.ballsColor + ) { + return false; + } + if ( + typeof hhit !== "undefined" && + gameState.bricks[hhit] !== gameState.ballsColor + ) { + return false; + } + if ( + typeof chit !== "undefined" && + gameState.bricks[chit] !== gameState.ballsColor + ) { + return false; + } + return true; +} diff --git a/src/render.ts b/src/render.ts index 3113cea..6c4ca60 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,26 +1,26 @@ -import {baseCombo, forEachLiveOne, liveCount} from "./gameStateMutators"; +import { baseCombo, forEachLiveOne, liveCount } from "./gameStateMutators"; import { - brickCenterX, - brickCenterY, - currentLevelInfo, - isTelekinesisActive, - isYoyoActive, - max_levels, + brickCenterX, + brickCenterY, + currentLevelInfo, + isTelekinesisActive, + isYoyoActive, + max_levels, } from "./game_utils"; -import {colorString, GameState} from "./types"; -import {t} from "./i18n/i18n"; -import {gameState} from "./game"; -import {isOptionOn} from "./options"; +import { colorString, GameState } from "./types"; +import { t } from "./i18n/i18n"; +import { gameState } from "./game"; +import { isOptionOn } from "./options"; export const gameCanvas = document.getElementById("game") as HTMLCanvasElement; export const ctx = gameCanvas.getContext("2d", { - alpha: false, + alpha: false, }) as CanvasRenderingContext2D; export const bombSVG = document.createElement("img"); bombSVG.src = - "data:image/svg+xml;base64," + - btoa(` + "data:image/svg+xml;base64," + + btoa(` `); @@ -28,738 +28,737 @@ export const background = document.createElement("img"); export const backgroundCanvas = document.createElement("canvas"); export function render(gameState: GameState) { - const level = currentLevelInfo(gameState); - const {width, height} = gameCanvas; - if (!width || !height) return; + const level = currentLevelInfo(gameState); + const { width, height } = gameCanvas; + if (!width || !height) return; - if (gameState.currentLevel || gameState.levelTime) { - menuLabel.innerText = t("play.current_lvl", { - level: gameState.currentLevel + 1, - max: max_levels(gameState), - }); - } else { - menuLabel.innerText = t("play.menu_label"); - } - scoreDisplay.innerText = `$${gameState.score}`; - - scoreDisplay.className = - gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : ""; - - // Clear - if (!isOptionOn("basic") && !level.color && level.svg) { - // Without this the light trails everything - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = 1; - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - - ctx.globalCompositeOperation = "screen"; - ctx.globalAlpha = 0.6; - forEachLiveOne(gameState.coins, (coin) => { - drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y); - }); - gameState.balls.forEach((ball) => { - drawFuzzyBall( - ctx, - gameState.ballsColor, - gameState.ballSize * 2, - ball.x, - ball.y, - ); - }); - ctx.globalAlpha = 0.5; - gameState.bricks.forEach((color, index) => { - if (!color) return; - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); - drawFuzzyBall( - ctx, - color == "black" ? "#666" : color, - gameState.brickWidth, - x, - y, - ); - }); - ctx.globalAlpha = 1; - forEachLiveOne(gameState.lights, (flash) => { - const {x, y, time, color, size, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - drawFuzzyBall(ctx, color, size, x, y); - }); - forEachLiveOne(gameState.particles, (flash) => { - const {x, y, time, color, size, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - drawFuzzyBall(ctx, color, size * 3, x, y); - }); - - // Decides how brights the bg black parts can get - ctx.globalAlpha = 0.2; - ctx.globalCompositeOperation = "multiply"; - ctx.fillStyle = "black"; - ctx.fillRect(0, 0, width, height); - // Decides how dark the background black parts are when lit (1=black) - ctx.globalAlpha = 0.8; - ctx.globalCompositeOperation = "multiply"; - if (level.svg && background.width && background.complete) { - if (backgroundCanvas.title !== level.name) { - backgroundCanvas.title = level.name; - backgroundCanvas.width = gameState.canvasWidth; - backgroundCanvas.height = gameState.canvasHeight; - const bgctx = backgroundCanvas.getContext( - "2d", - ) as CanvasRenderingContext2D; - bgctx.fillStyle = level.color || "#000"; - bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight); - const pattern = ctx.createPattern(background, "repeat"); - if (pattern) { - bgctx.fillStyle = pattern; - bgctx.fillRect(0, 0, width, height); - } - } - - ctx.drawImage(backgroundCanvas, 0, 0); - } else { - // Background not loaded yes - ctx.fillStyle = "#000"; - ctx.fillRect(0, 0, width, height); - } - } else { - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = level.color || "#000"; - ctx.fillRect(0, 0, width, height); - forEachLiveOne(gameState.particles, (flash) => { - const {x, y, time, color, size, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); - drawBall(ctx, color, size, x, y); - }); - } - - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5; - const shaked = lastExplosionDelay < 200 && !isOptionOn("basic"); - if (shaked) { - const amplitude = - ((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay; - ctx.translate( - Math.sin(Date.now()) * amplitude, - Math.sin(Date.now() + 36) * amplitude, - ); - } - if (gameState.perks.bigger_explosions && !isOptionOn("basic")) { - if (shaked) { - gameCanvas.style.filter = - "brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")"; - } else { - gameCanvas.style.filter = ""; - } - } - // Coins - ctx.globalAlpha = 1; - forEachLiveOne(gameState.coins, (coin) => { - ctx.globalCompositeOperation = - coin.color === "gold" || level.color ? "source-over" : "screen"; - drawCoin( - ctx, - coin.color, - coin.size, - coin.x, - coin.y, - level.color || "black", - coin.a, - ); + if (gameState.currentLevel || gameState.levelTime) { + menuLabel.innerText = t("play.current_lvl", { + level: gameState.currentLevel + 1, + max: max_levels(gameState), }); + } else { + menuLabel.innerText = t("play.menu_label"); + } + scoreDisplay.innerText = `$${gameState.score}`; - // Black shadow around balls - if (!isOptionOn("basic")) { - ctx.globalCompositeOperation = "source-over"; - ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20); - gameState.balls.forEach((ball) => { - drawBall( - ctx, - level.color || "#000", - gameState.ballSize * 6, - ball.x, - ball.y, - ); - }); - } + scoreDisplay.className = + gameState.lastScoreIncrease > gameState.levelTime - 500 ? "active" : ""; + // Clear + if (!isOptionOn("basic") && !level.color && level.svg) { + // Without this the light trails everything ctx.globalCompositeOperation = "source-over"; - renderAllBricks(); + ctx.globalAlpha = 1; + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); ctx.globalCompositeOperation = "screen"; - forEachLiveOne(gameState.texts, (flash) => { - const {x, y, time, color, size, duration} = flash; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); - ctx.globalCompositeOperation = "source-over"; - drawText(ctx, flash.text, color, size, x, y - elapsed / 10); + ctx.globalAlpha = 0.6; + forEachLiveOne(gameState.coins, (coin) => { + drawFuzzyBall(ctx, coin.color, gameState.coinSize * 2, coin.x, coin.y); }); - - forEachLiveOne(gameState.particles, (particle) => { - const {x, y, time, color, size, duration} = particle; - const elapsed = gameState.levelTime - time; - ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); - ctx.globalCompositeOperation = "screen"; - drawBall(ctx, color, size, x, y); - drawFuzzyBall(ctx, color, size, x, y); - }); - - if (gameState.perks.extra_life) { - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; - ctx.fillStyle = gameState.puckColor; - for (let i = 0; i < gameState.perks.extra_life; i++) { - ctx.fillRect( - gameState.perks.unbounded ? 0 : gameState.offsetXRoundedDown, - gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, - gameState.perks.unbounded ? gameState.canvasWidth : gameState.gameZoneWidthRoundedUp, - 1, - ); - } - } - - ctx.globalAlpha = 1; - ctx.globalCompositeOperation = "source-over"; gameState.balls.forEach((ball) => { - // The white border around is to distinguish colored balls from coins/bg - drawBall( - ctx, - gameState.ballsColor, - gameState.ballSize, - ball.x, - ball.y, - gameState.puckColor, - ); - - if (isTelekinesisActive(gameState, ball) || isYoyoActive(gameState, ball)) { - ctx.strokeStyle = gameState.puckColor; - ctx.beginPath(); - ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight ); - ctx.bezierCurveTo( - gameState.puckPosition, - gameState.gameZoneHeight, - gameState.puckPosition, - ball.y, - ball.x, - ball.y, - ); - ctx.stroke(); - } - - - + drawFuzzyBall( + ctx, + gameState.ballsColor, + gameState.ballSize * 2, + ball.x, + ball.y, + ); }); - // The puck + ctx.globalAlpha = 0.5; + gameState.bricks.forEach((color, index) => { + if (!color) return; + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); + drawFuzzyBall( + ctx, + color == "black" ? "#666" : color, + gameState.brickWidth, + x, + y, + ); + }); + ctx.globalAlpha = 1; + forEachLiveOne(gameState.lights, (flash) => { + const { x, y, time, color, size, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + drawFuzzyBall(ctx, color, size, x, y); + }); + forEachLiveOne(gameState.particles, (flash) => { + const { x, y, time, color, size, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + drawFuzzyBall(ctx, color, size * 3, x, y); + }); + + // Decides how brights the bg black parts can get + ctx.globalAlpha = 0.2; + ctx.globalCompositeOperation = "multiply"; + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, width, height); + // Decides how dark the background black parts are when lit (1=black) + ctx.globalAlpha = 0.8; + ctx.globalCompositeOperation = "multiply"; + if (level.svg && background.width && background.complete) { + if (backgroundCanvas.title !== level.name) { + backgroundCanvas.title = level.name; + backgroundCanvas.width = gameState.canvasWidth; + backgroundCanvas.height = gameState.canvasHeight; + const bgctx = backgroundCanvas.getContext( + "2d", + ) as CanvasRenderingContext2D; + bgctx.fillStyle = level.color || "#000"; + bgctx.fillRect(0, 0, gameState.canvasWidth, gameState.canvasHeight); + const pattern = ctx.createPattern(background, "repeat"); + if (pattern) { + bgctx.fillStyle = pattern; + bgctx.fillRect(0, 0, width, height); + } + } + + ctx.drawImage(backgroundCanvas, 0, 0); + } else { + // Background not loaded yes + ctx.fillStyle = "#000"; + ctx.fillRect(0, 0, width, height); + } + } else { ctx.globalAlpha = 1; ctx.globalCompositeOperation = "source-over"; - if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) { - drawPuck( - ctx, - "red", - gameState.puckWidth, - gameState.puckHeight, - -2, - !!gameState.perks.concave_puck, - ); + ctx.fillStyle = level.color || "#000"; + ctx.fillRect(0, 0, width, height); + forEachLiveOne(gameState.particles, (flash) => { + const { x, y, time, color, size, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.min(1, 2 - (elapsed / duration) * 2); + drawBall(ctx, color, size, x, y); + }); + } + + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + const lastExplosionDelay = Date.now() - gameState.lastExplosion + 5; + const shaked = lastExplosionDelay < 200 && !isOptionOn("basic"); + if (shaked) { + const amplitude = + ((gameState.perks.bigger_explosions + 1) * 50) / lastExplosionDelay; + ctx.translate( + Math.sin(Date.now()) * amplitude, + Math.sin(Date.now() + 36) * amplitude, + ); + } + if (gameState.perks.bigger_explosions && !isOptionOn("basic")) { + if (shaked) { + gameCanvas.style.filter = + "brightness(" + (1 + 100 / (1 + lastExplosionDelay)) + ")"; + } else { + gameCanvas.style.filter = ""; } - drawPuck( + } + // Coins + ctx.globalAlpha = 1; + forEachLiveOne(gameState.coins, (coin) => { + ctx.globalCompositeOperation = + coin.color === "gold" || level.color ? "source-over" : "screen"; + drawCoin( + ctx, + coin.color, + coin.size, + coin.x, + coin.y, + level.color || "black", + coin.a, + ); + }); + + // Black shadow around balls + if (!isOptionOn("basic")) { + ctx.globalCompositeOperation = "source-over"; + ctx.globalAlpha = Math.min(0.8, liveCount(gameState.coins) / 20); + gameState.balls.forEach((ball) => { + drawBall( ctx, - gameState.puckColor, - gameState.puckWidth, - gameState.puckHeight, - 0, - !!gameState.perks.concave_puck, + level.color || "#000", + gameState.ballSize * 6, + ball.x, + ball.y, + ); + }); + } + + ctx.globalCompositeOperation = "source-over"; + renderAllBricks(); + + ctx.globalCompositeOperation = "screen"; + forEachLiveOne(gameState.texts, (flash) => { + const { x, y, time, color, size, duration } = flash; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); + ctx.globalCompositeOperation = "source-over"; + drawText(ctx, flash.text, color, size, x, y - elapsed / 10); + }); + + forEachLiveOne(gameState.particles, (particle) => { + const { x, y, time, color, size, duration } = particle; + const elapsed = gameState.levelTime - time; + ctx.globalAlpha = Math.max(0, Math.min(1, 2 - (elapsed / duration) * 2)); + ctx.globalCompositeOperation = "screen"; + drawBall(ctx, color, size, x, y); + drawFuzzyBall(ctx, color, size, x, y); + }); + + if (gameState.perks.extra_life) { + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + ctx.fillStyle = gameState.puckColor; + for (let i = 0; i < gameState.perks.extra_life; i++) { + ctx.fillRect( + gameState.perks.unbounded ? 0 : gameState.offsetXRoundedDown, + gameState.gameZoneHeight - gameState.puckHeight / 2 + 2 * i, + gameState.perks.unbounded + ? gameState.canvasWidth + : gameState.gameZoneWidthRoundedUp, + 1, + ); + } + } + + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + gameState.balls.forEach((ball) => { + // The white border around is to distinguish colored balls from coins/bg + drawBall( + ctx, + gameState.ballsColor, + gameState.ballSize, + ball.x, + ball.y, + gameState.puckColor, ); - if (gameState.combo > 1) { - ctx.globalCompositeOperation = "source-over"; - const comboText = "x " + gameState.combo; - const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8; - const totalWidth = comboTextWidth + gameState.coinSize * 2; - const left = gameState.puckPosition - totalWidth / 2; - if (totalWidth < gameState.puckWidth) { - drawCoin( - ctx, - "gold", - gameState.coinSize, - left + gameState.coinSize / 2, - gameState.gameZoneHeight - gameState.puckHeight / 2, - gameState.puckColor, - 0, - ); - drawText( - ctx, - comboText, - "#000", - gameState.puckHeight, - left + gameState.coinSize * 1.5, - gameState.gameZoneHeight - gameState.puckHeight / 2, - true, - ); - } else { - drawText( - ctx, - comboText, - "#FFF", - gameState.puckHeight, - gameState.puckPosition, - gameState.gameZoneHeight - gameState.puckHeight / 2, - false, - ); - } + if (isTelekinesisActive(gameState, ball) || isYoyoActive(gameState, ball)) { + ctx.strokeStyle = gameState.puckColor; + ctx.beginPath(); + ctx.moveTo(gameState.puckPosition, gameState.gameZoneHeight); + ctx.bezierCurveTo( + gameState.puckPosition, + gameState.gameZoneHeight, + gameState.puckPosition, + ball.y, + ball.x, + ball.y, + ); + ctx.stroke(); } - // Borders - const hasCombo = gameState.combo > baseCombo(gameState); + }); + // The puck + ctx.globalAlpha = 1; + ctx.globalCompositeOperation = "source-over"; + if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) { + drawPuck( + ctx, + "red", + gameState.puckWidth, + gameState.puckHeight, + -2, + !!gameState.perks.concave_puck, + ); + } + drawPuck( + ctx, + gameState.puckColor, + gameState.puckWidth, + gameState.puckHeight, + 0, + !!gameState.perks.concave_puck, + ); + + if (gameState.combo > 1) { ctx.globalCompositeOperation = "source-over"; - - ctx.globalAlpha = gameState.perks.unbounded ? 0.1 : 1 - if (gameState.offsetXRoundedDown) { - // draw outside of gaming area to avoid capturing borders in recordings - ctx.fillStyle = - hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor; - ctx.fillRect(gameState.offsetX - 1, 0, 1, height); - ctx.fillStyle = - hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; - ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); + const comboText = "x " + gameState.combo; + const comboTextWidth = (comboText.length * gameState.puckHeight) / 1.8; + const totalWidth = comboTextWidth + gameState.coinSize * 2; + const left = gameState.puckPosition - totalWidth / 2; + if (totalWidth < gameState.puckWidth) { + drawCoin( + ctx, + "gold", + gameState.coinSize, + left + gameState.coinSize / 2, + gameState.gameZoneHeight - gameState.puckHeight / 2, + gameState.puckColor, + 0, + ); + drawText( + ctx, + comboText, + "#000", + gameState.puckHeight, + left + gameState.coinSize * 1.5, + gameState.gameZoneHeight - gameState.puckHeight / 2, + true, + ); } else { - ctx.fillStyle = "red"; - if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); - if (hasCombo && gameState.perks.right_is_lava) - ctx.fillRect(width - 1, 0, 1, height); + drawText( + ctx, + comboText, + "#FFF", + gameState.puckHeight, + gameState.puckPosition, + gameState.gameZoneHeight - gameState.puckHeight / 2, + false, + ); } + } + // Borders + const hasCombo = gameState.combo > baseCombo(gameState); + ctx.globalCompositeOperation = "source-over"; - if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) { - ctx.fillStyle = "red"; - ctx.fillRect( - gameState.offsetXRoundedDown, - 0, - gameState.gameZoneWidthRoundedUp, - 1, - ); - } - const redBottom = - gameState.perks.compound_interest && gameState.combo > baseCombo(gameState); - ctx.fillStyle = redBottom ? "red" : gameState.puckColor; - if (isOptionOn("mobile-mode")) { - ctx.fillRect( - gameState.offsetXRoundedDown, - gameState.gameZoneHeight, - gameState.gameZoneWidthRoundedUp, - 1, - ); - if (!gameState.running) { - drawText( - ctx, - t("play.mobile_press_to_play"), - gameState.puckColor, - gameState.puckHeight, - gameState.canvasWidth / 2, - gameState.gameZoneHeight + - (gameState.canvasHeight - gameState.gameZoneHeight) / 2, - ); - } - } else if (redBottom) { - ctx.fillRect( - gameState.offsetXRoundedDown, - gameState.gameZoneHeight - 1, - gameState.gameZoneWidthRoundedUp, - 1, - ); - } + ctx.globalAlpha = gameState.perks.unbounded ? 0.1 : 1; + if (gameState.offsetXRoundedDown) { + // draw outside of gaming area to avoid capturing borders in recordings + ctx.fillStyle = + hasCombo && gameState.perks.left_is_lava ? "red" : gameState.puckColor; + ctx.fillRect(gameState.offsetX - 1, 0, 1, height); + ctx.fillStyle = + hasCombo && gameState.perks.right_is_lava ? "red" : gameState.puckColor; + ctx.fillRect(width - gameState.offsetX + 1, 0, 1, height); + } else { + ctx.fillStyle = "red"; + if (hasCombo && gameState.perks.left_is_lava) ctx.fillRect(0, 0, 1, height); + if (hasCombo && gameState.perks.right_is_lava) + ctx.fillRect(width - 1, 0, 1, height); + } - if (shaked) { - ctx.resetTransform(); + if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) { + ctx.fillStyle = "red"; + ctx.fillRect( + gameState.offsetXRoundedDown, + 0, + gameState.gameZoneWidthRoundedUp, + 1, + ); + } + const redBottom = + gameState.perks.compound_interest && gameState.combo > baseCombo(gameState); + ctx.fillStyle = redBottom ? "red" : gameState.puckColor; + if (isOptionOn("mobile-mode")) { + ctx.fillRect( + gameState.offsetXRoundedDown, + gameState.gameZoneHeight, + gameState.gameZoneWidthRoundedUp, + 1, + ); + if (!gameState.running) { + drawText( + ctx, + t("play.mobile_press_to_play"), + gameState.puckColor, + gameState.puckHeight, + gameState.canvasWidth / 2, + gameState.gameZoneHeight + + (gameState.canvasHeight - gameState.gameZoneHeight) / 2, + ); } + } else if (redBottom) { + ctx.fillRect( + gameState.offsetXRoundedDown, + gameState.gameZoneHeight - 1, + gameState.gameZoneWidthRoundedUp, + 1, + ); + } + + if (shaked) { + ctx.resetTransform(); + } } let cachedBricksRender = document.createElement("canvas"); let cachedBricksRenderKey = ""; export function renderAllBricks() { - ctx.globalAlpha = 1; + ctx.globalAlpha = 1; - const redBorderOnBricksWithWrongColor = - gameState.combo > baseCombo(gameState) && - gameState.perks.picky_eater && - !isOptionOn("basic"); + const redBorderOnBricksWithWrongColor = + gameState.combo > baseCombo(gameState) && + gameState.perks.picky_eater && + !isOptionOn("basic"); - const newKey = - gameState.gameZoneWidth + - "_" + - gameState.bricks.join("_") + - bombSVG.complete + - "_" + - redBorderOnBricksWithWrongColor + - "_" + - gameState.ballsColor + - "_" + - gameState.perks.pierce_color; - if (newKey !== cachedBricksRenderKey) { - cachedBricksRenderKey = newKey; + const newKey = + gameState.gameZoneWidth + + "_" + + gameState.bricks.join("_") + + bombSVG.complete + + "_" + + redBorderOnBricksWithWrongColor + + "_" + + gameState.ballsColor + + "_" + + gameState.perks.pierce_color; + if (newKey !== cachedBricksRenderKey) { + cachedBricksRenderKey = newKey; - cachedBricksRender.width = gameState.gameZoneWidth; - cachedBricksRender.height = gameState.gameZoneWidth + 1; - const canctx = cachedBricksRender.getContext( - "2d", - ) as CanvasRenderingContext2D; - canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth); - canctx.resetTransform(); - canctx.translate(-gameState.offsetX, 0); - // Bricks - gameState.bricks.forEach((color, index) => { - const x = brickCenterX(gameState, index), - y = brickCenterY(gameState, index); + cachedBricksRender.width = gameState.gameZoneWidth; + cachedBricksRender.height = gameState.gameZoneWidth + 1; + const canctx = cachedBricksRender.getContext( + "2d", + ) as CanvasRenderingContext2D; + canctx.clearRect(0, 0, gameState.gameZoneWidth, gameState.gameZoneWidth); + canctx.resetTransform(); + canctx.translate(-gameState.offsetX, 0); + // Bricks + gameState.bricks.forEach((color, index) => { + const x = brickCenterX(gameState, index), + y = brickCenterY(gameState, index); - if (!color) return; + if (!color) return; - const borderColor = - (gameState.ballsColor !== color && - color !== "black" && - redBorderOnBricksWithWrongColor && - "red") || - color; + const borderColor = + (gameState.ballsColor !== color && + color !== "black" && + redBorderOnBricksWithWrongColor && + "red") || + color; - drawBrick(canctx, color, borderColor, x, y); - if (color === "black") { - canctx.globalCompositeOperation = "source-over"; - drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); - } - }); - } + drawBrick(canctx, color, borderColor, x, y); + if (color === "black") { + canctx.globalCompositeOperation = "source-over"; + drawIMG(canctx, bombSVG, gameState.brickWidth, x, y); + } + }); + } - ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); + ctx.drawImage(cachedBricksRender, gameState.offsetX, 0); } let cachedGraphics: { [k: string]: HTMLCanvasElement } = {}; export function drawPuck( - ctx: CanvasRenderingContext2D, - color: colorString, - puckWidth: number, - puckHeight: number, - yOffset = 0, - flipped: boolean, + ctx: CanvasRenderingContext2D, + color: colorString, + puckWidth: number, + puckHeight: number, + yOffset = 0, + flipped: boolean, ) { - const key = - "puck" + color + "_" + puckWidth + "_" + puckHeight + "_" + flipped; + const key = + "puck" + color + "_" + puckWidth + "_" + puckHeight + "_" + flipped; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = puckWidth; - can.height = puckHeight * 2; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = puckWidth; + can.height = puckHeight * 2; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; - canctx.beginPath(); - canctx.moveTo(0, puckHeight * 2); + canctx.beginPath(); + canctx.moveTo(0, puckHeight * 2); - if (flipped) { - canctx.lineTo(0, puckHeight * 0.75); - canctx.bezierCurveTo( - puckWidth / 2, - puckHeight, - puckWidth / 2, - puckHeight * 1, - puckWidth, - puckHeight * 0.75, - ); - canctx.lineTo(puckWidth, puckHeight * 2); - } else { - canctx.lineTo(0, puckHeight * 1.25); - canctx.bezierCurveTo( - 0, - puckHeight * 0.75, - puckWidth, - puckHeight * 0.75, - puckWidth, - puckHeight * 1.25, - ); - canctx.lineTo(puckWidth, puckHeight * 2); - } - - canctx.fill(); - cachedGraphics[key] = can; + if (flipped) { + canctx.lineTo(0, puckHeight * 0.75); + canctx.bezierCurveTo( + puckWidth / 2, + puckHeight, + puckWidth / 2, + puckHeight * 1, + puckWidth, + puckHeight * 0.75, + ); + canctx.lineTo(puckWidth, puckHeight * 2); + } else { + canctx.lineTo(0, puckHeight * 1.25); + canctx.bezierCurveTo( + 0, + puckHeight * 0.75, + puckWidth, + puckHeight * 0.75, + puckWidth, + puckHeight * 1.25, + ); + canctx.lineTo(puckWidth, puckHeight * 2); } - ctx.drawImage( - cachedGraphics[key], - Math.round(gameState.puckPosition - puckWidth / 2), - gameState.gameZoneHeight - puckHeight * 2 + yOffset, - ); + canctx.fill(); + cachedGraphics[key] = can; + } + + ctx.drawImage( + cachedGraphics[key], + Math.round(gameState.puckPosition - puckWidth / 2), + gameState.gameZoneHeight - puckHeight * 2 + yOffset, + ); } export function drawBall( - ctx: CanvasRenderingContext2D, - color: colorString, - width: number, - x: number, - y: number, - borderColor = "", + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, + borderColor = "", ) { - const key = "ball" + color + "_" + width + "_" + borderColor; + const key = "ball" + color + "_" + width + "_" + borderColor; - const size = Math.round(width); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + const size = Math.round(width); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.beginPath(); - canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); - if (borderColor) { - canctx.lineWidth = 2; - canctx.strokeStyle = borderColor; - canctx.stroke(); - } - - cachedGraphics[key] = can; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.beginPath(); + canctx.arc(size / 2, size / 2, Math.round(size / 2) - 1, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); + if (borderColor) { + canctx.lineWidth = 2; + canctx.strokeStyle = borderColor; + canctx.stroke(); } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } const angles = 32; export function drawCoin( - ctx: CanvasRenderingContext2D, - color: colorString, - size: number, - x: number, - y: number, - borderColor: colorString, - rawAngle: number, + ctx: CanvasRenderingContext2D, + color: colorString, + size: number, + x: number, + y: number, + borderColor: colorString, + rawAngle: number, ) { - const angle = - ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % - angles; - const key = - "coin with halo" + - "_" + - color + - "_" + - size + - "_" + - borderColor + - "_" + - (color === "gold" ? angle : "whatever"); + const angle = + ((Math.round((rawAngle / Math.PI) * 2 * angles) % angles) + angles) % + angles; + const key = + "coin with halo" + + "_" + + color + + "_" + + size + + "_" + + borderColor + + "_" + + (color === "gold" ? angle : "whatever"); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - // coin - canctx.beginPath(); - canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); - canctx.fillStyle = color; - canctx.fill(); + // coin + canctx.beginPath(); + canctx.arc(size / 2, size / 2, size / 2, 0, 2 * Math.PI); + canctx.fillStyle = color; + canctx.fill(); - 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), + ); } export function drawFuzzyBall( - ctx: CanvasRenderingContext2D, - color: colorString, - width: number, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + color: colorString, + width: number, + x: number, + y: number, ) { - const key = "fuzzy-circle" + color + "_" + width; - if (!color) debugger; - const size = Math.round(width * 3); - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + const key = "fuzzy-circle" + color + "_" + width; + if (!color) debugger; + const size = Math.round(width * 3); + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - const gradient = canctx.createRadialGradient( - size / 2, - size / 2, - 0, - size / 2, - size / 2, - size / 2, - ); - gradient.addColorStop(0, color); - gradient.addColorStop(1, "transparent"); - canctx.fillStyle = gradient; - canctx.fillRect(0, 0, size, size); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const gradient = canctx.createRadialGradient( + size / 2, + size / 2, + 0, + size / 2, + size / 2, + size / 2, ); + gradient.addColorStop(0, color); + gradient.addColorStop(1, "transparent"); + canctx.fillStyle = gradient; + canctx.fillRect(0, 0, size, size); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } export function drawBrick( - ctx: CanvasRenderingContext2D, - color: colorString, - borderColor: colorString, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + color: colorString, + borderColor: colorString, + x: number, + y: number, ) { - const tlx = Math.ceil(x - gameState.brickWidth / 2); - const tly = Math.ceil(y - gameState.brickWidth / 2); - const brx = Math.ceil(x + gameState.brickWidth / 2) - 1; - const bry = Math.ceil(y + gameState.brickWidth / 2) - 1; + const tlx = Math.ceil(x - gameState.brickWidth / 2); + const tly = Math.ceil(y - gameState.brickWidth / 2); + const brx = Math.ceil(x + gameState.brickWidth / 2) - 1; + const bry = Math.ceil(y + gameState.brickWidth / 2) - 1; - const width = brx - tlx, - height = bry - tly; - const key = "brick" + color + "_" + borderColor + "_" + width + "_" + height; + 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; + 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(); + 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 + cachedGraphics[key] = can; + } + ctx.drawImage(cachedGraphics[key], tlx, tly, width, height); + // It's not easy to have a 1px gap between bricks without antialiasing } export function roundRect( - ctx: CanvasRenderingContext2D, - x: number, - y: number, - width: number, - height: number, - radius: number, + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, + height: number, + radius: number, ) { - ctx.beginPath(); - ctx.moveTo(x + radius, y); - ctx.lineTo(x + width - radius, y); - ctx.quadraticCurveTo(x + width, y, x + width, y + radius); - ctx.lineTo(x + width, y + height - radius); - ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); - ctx.lineTo(x + radius, y + height); - ctx.quadraticCurveTo(x, y + height, x, y + height - radius); - ctx.lineTo(x, y + radius); - ctx.quadraticCurveTo(x, y, x + radius, y); - ctx.closePath(); + ctx.beginPath(); + ctx.moveTo(x + radius, y); + ctx.lineTo(x + width - radius, y); + ctx.quadraticCurveTo(x + width, y, x + width, y + radius); + ctx.lineTo(x + width, y + height - radius); + ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height); + ctx.lineTo(x + radius, y + height); + ctx.quadraticCurveTo(x, y + height, x, y + height - radius); + ctx.lineTo(x, y + radius); + ctx.quadraticCurveTo(x, y, x + radius, y); + ctx.closePath(); } export function drawIMG( - ctx: CanvasRenderingContext2D, - img: HTMLImageElement, - size: number, - x: number, - y: number, + ctx: CanvasRenderingContext2D, + img: HTMLImageElement, + size: number, + x: number, + y: number, ) { - const key = "svg" + img + "_" + size + "_" + img.complete; + const key = "svg" + img + "_" + size + "_" + img.complete; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = size; - can.height = size; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = size; + can.height = size; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; - const ratio = size / Math.max(img.width, img.height); - const w = img.width * ratio; - const h = img.height * ratio; - canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); + const ratio = size / Math.max(img.width, img.height); + const w = img.width * ratio; + const h = img.height * ratio; + canctx.drawImage(img, (size - w) / 2, (size - h) / 2, w, h); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - Math.round(x - size / 2), - Math.round(y - size / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + Math.round(x - size / 2), + Math.round(y - size / 2), + ); } export function drawText( - ctx: CanvasRenderingContext2D, - text: string, - color: colorString, - fontSize: number, - x: number, - y: number, - left = false, + ctx: CanvasRenderingContext2D, + text: string, + color: colorString, + fontSize: number, + x: number, + y: number, + left = false, ) { - const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; + const key = "text" + text + "_" + color + "_" + fontSize + "_" + left; - if (!cachedGraphics[key]) { - const can = document.createElement("canvas"); - can.width = fontSize * text.length; - can.height = fontSize; - const canctx = can.getContext("2d") as CanvasRenderingContext2D; - canctx.fillStyle = color; - canctx.textAlign = left ? "left" : "center"; - canctx.textBaseline = "middle"; - canctx.font = fontSize + "px monospace"; + if (!cachedGraphics[key]) { + const can = document.createElement("canvas"); + can.width = fontSize * text.length; + can.height = fontSize; + const canctx = can.getContext("2d") as CanvasRenderingContext2D; + canctx.fillStyle = color; + canctx.textAlign = left ? "left" : "center"; + canctx.textBaseline = "middle"; + canctx.font = fontSize + "px monospace"; - canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); + canctx.fillText(text, left ? 0 : can.width / 2, can.height / 2, can.width); - cachedGraphics[key] = can; - } - ctx.drawImage( - cachedGraphics[key], - left ? x : Math.round(x - cachedGraphics[key].width / 2), - Math.round(y - cachedGraphics[key].height / 2), - ); + cachedGraphics[key] = can; + } + ctx.drawImage( + cachedGraphics[key], + left ? x : Math.round(x - cachedGraphics[key].width / 2), + Math.round(y - cachedGraphics[key].height / 2), + ); } export const scoreDisplay = document.getElementById( - "score", + "score", ) as HTMLButtonElement; const menuLabel = document.getElementById("menuLabel") as HTMLButtonElement; diff --git a/src/types.d.ts b/src/types.d.ts index ce4fd97..cab2f77 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -244,7 +244,7 @@ export type GameState = { runStatistics: RunStats; lastOffered: Partial<{ [k in PerkId]: number }>; levelTime: number; - noBricksSince: number ; + noBricksSince: number; levelWallBounces: number; autoCleanUses: number; aboutToPlaySound: { diff --git a/src/upgrades.test.ts b/src/upgrades.test.ts index 2ff1c3e..25f1247 100644 --- a/src/upgrades.test.ts +++ b/src/upgrades.test.ts @@ -1,11 +1,11 @@ - import _rawLevelsList from "./data/levels.json"; -import {rawUpgrades} from "./upgrades"; +import { rawUpgrades } from "./upgrades"; -describe("rawUpgrades", ()=>{ - - it('has an icon for each upgrade',()=>{ - const missingIcon = rawUpgrades.map(u=>u.id).filter(id=>!_rawLevelsList.find(l=>l.name === 'icon:'+id)) - expect(missingIcon.join(', ')).toEqual('') - }) -}) +describe("rawUpgrades", () => { + it("has an icon for each upgrade", () => { + const missingIcon = rawUpgrades + .map((u) => u.id) + .filter((id) => !_rawLevelsList.find((l) => l.name === "icon:" + id)); + expect(missingIcon.join(", ")).toEqual(""); + }); +}); diff --git a/src/upgrades.ts b/src/upgrades.ts index a88aa68..6a40632 100644 --- a/src/upgrades.ts +++ b/src/upgrades.ts @@ -383,7 +383,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:70000, + threshold: 70000, giftable: false, id: "asceticism", max: 1, @@ -393,7 +393,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:75000, + threshold: 75000, giftable: false, id: "unbounded", max: 1, @@ -403,7 +403,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:80000, + threshold: 80000, giftable: false, id: "shunt", max: 1, @@ -413,7 +413,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:85000, + threshold: 85000, giftable: false, id: "yoyo", max: 2, @@ -423,17 +423,17 @@ export const rawUpgrades = [ }, { requires: "", - threshold:90000, + threshold: 90000, giftable: false, id: "nbricks", max: 3, name: t("upgrades.nbricks.name"), - help: (lvl: number) => t("upgrades.nbricks.help",{lvl}), + help: (lvl: number) => t("upgrades.nbricks.help", { lvl }), fullHelp: t("upgrades.nbricks.fullHelp"), }, { requires: "", - threshold:95000, + threshold: 95000, giftable: false, id: "etherealcoins", max: 1, @@ -443,7 +443,7 @@ export const rawUpgrades = [ }, { requires: "multiball", - threshold:100000, + threshold: 100000, giftable: false, id: "shocks", max: 1, @@ -453,7 +453,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:105000, + threshold: 105000, giftable: false, id: "zen", max: 1, @@ -463,7 +463,7 @@ export const rawUpgrades = [ }, { requires: "extra_life", - threshold:110000, + threshold: 110000, giftable: false, id: "sacrifice", max: 1, @@ -474,27 +474,27 @@ export const rawUpgrades = [ { requires: "", - threshold:115000, + threshold: 115000, giftable: false, id: "trampoline", max: 3, name: t("upgrades.trampoline.name"), - help: (lvl: number) => t("upgrades.trampoline.help",{lvl}), + help: (lvl: number) => t("upgrades.trampoline.help", { lvl }), fullHelp: t("upgrades.trampoline.fullHelp"), }, { requires: "", - threshold:120000, + threshold: 120000, giftable: false, id: "ghost_coins", max: 1, name: t("upgrades.ghost_coins.name"), - help: (lvl: number) => t("upgrades.ghost_coins.help",{lvl}), + help: (lvl: number) => t("upgrades.ghost_coins.help", { lvl }), fullHelp: t("upgrades.ghost_coins.fullHelp"), }, { requires: "", - threshold:125000, + threshold: 125000, giftable: false, id: "forgiving", max: 1, @@ -504,7 +504,7 @@ export const rawUpgrades = [ }, { requires: "", - threshold:130000, + threshold: 130000, giftable: false, id: "ball_attracts_coins", max: 3, @@ -512,5 +512,4 @@ export const rawUpgrades = [ help: (lvl: number) => t("upgrades.ball_attracts_coins.help"), fullHelp: t("upgrades.ball_attracts_coins.fullHelp"), }, - ] as const;