import { Ball, BallLike, Coin, colorString, GameState, LightFlash, ParticleFlash, PerkId, ReusableArray, TextFlash, } from "./types"; import { brickCenterX, brickCenterY, clamp, countBricksAbove, countBricksBelow, currentLevelInfo, distance2, distanceBetween, getMajorityValue, getPossibleUpgrades, getRowColIndex, isTelekinesisActive, isYoyoActive, max_levels, shouldPierceByColor, } from "./game_utils"; import { t } from "./i18n/i18n"; import { icons } from "./loadGameData"; import { addToTotalScore, getCurrentMaxCoins, getCurrentMaxParticles, } from "./settings"; import { background } from "./render"; import { gameOver } from "./gameOver"; import { brickIndex, fitSize, gameState, hasBrick, hitsSomething, openShortRunUpgradesPicker, pause, } from "./game"; import { stopRecording } from "./recording"; import { isOptionOn } from "./options"; import { openAdventureRunUpgradesPicker } from "./adventure"; 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; } function getBallDefaultVx(gameState: GameState) { return ( (gameState.perks.concave_puck ? 0 : 1) * (Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed) ); } export function resetBalls(gameState: GameState) { // Always compute speed first normalizeGameState(gameState); const count = 1 + (gameState.perks?.multiball || 0); const perBall = gameState.puckWidth / (count + 1); gameState.balls = []; gameState.ballsColor = "#FFF"; if (gameState.perks.picky_eater || gameState.perks.pierce_color) { gameState.ballsColor = getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF"; } for (let i = 0; i < count; i++) { const x = gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1); const vx = getBallDefaultVx(gameState); gameState.balls.push({ x, previousX: x, y: gameState.gameZoneHeight - 1.5 * gameState.ballSize, previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize, vx, previousVX: vx, vy: -gameState.baseSpeed, previousVY: -gameState.baseSpeed, // sx: 0, // sy: 0, piercePoints: gameState.perks.pierce * 3, hitSinceBounce: 0, brokenSinceBounce: 0, hitItem: [], sapperUses: 0, }); } gameState.ballStickToPuck = true; } 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); ball.x = x; ball.previousX = x; ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize; ball.previousY = ball.y; // ball.vx = vx; // ball.previousVX = ball.vx; // ball.vy = -gameState.baseSpeed; // ball.previousVY = ball.vy; // ball.sx = 0; // ball.sy = 0; ball.hitItem = []; ball.hitSinceBounce = 0; ball.brokenSinceBounce = 0; ball.piercePoints = gameState.perks.pierce * 3; }); } export function normalizeGameState(gameState: GameState) { // This function resets most parameters on the state to correct values, and should be used even when the game is paused gameState.baseSpeed = Math.max( 3, gameState.gameZoneWidth / 12 / 10 + gameState.currentLevel / (gameState.isAdventureMode ? 30 : 3) + gameState.levelTime / (30 * 1000) - gameState.perks.slow_down * 2, ); gameState.puckWidth = (gameState.gameZoneWidth / 12) * (3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck); let minX = gameState.perks.corner_shot && gameState.levelTime ? gameState.offsetXRoundedDown - gameState.puckWidth / 2 : gameState.offsetXRoundedDown + gameState.puckWidth / 2; let maxX = gameState.perks.corner_shot && gameState.levelTime ? gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp + gameState.puckWidth / 2 : gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp - gameState.puckWidth / 2; gameState.puckPosition = clamp(gameState.puckPosition, minX, maxX); if (gameState.ballStickToPuck) { putBallsAtPuck(gameState); } if ( Math.abs(gameState.lastPuckPosition - gameState.puckPosition) > 1 && gameState.running ) { gameState.lastPuckMove = gameState.levelTime; } gameState.lastPuckPosition = gameState.puckPosition; } export function baseCombo(gameState: GameState) { return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5; } export function resetCombo( gameState: GameState, x: number | undefined, y: number | undefined, ) { 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, ); } 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); } } return lost; } export function decreaseCombo( gameState: GameState, by: number, x: number, y: number, ) { const prev = gameState.combo; gameState.combo = Math.max(baseCombo(gameState), gameState.combo - by); const lost = Math.max(0, prev - gameState.combo); if (lost) { 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, ) { if (!!isOptionOn("basic")) return; if (liveCount(gameState.particles) > getCurrentMaxParticles()) { // Avoid freezing when lots of explosion happen at once count = 1; } for (let i = 0; i < count; i++) { makeParticle( gameState, x + ((Math.random() - 0.5) * gameState.brickWidth) / 2, y + ((Math.random() - 0.5) * gameState.brickWidth) / 2, (Math.random() - 0.5) * 30, (Math.random() - 0.5) * 30, color, false, ); } } export function spawnImplosion( gameState: GameState, count: number, x: number, y: number, color: string, ) { if (!!isOptionOn("basic")) return; if (liveCount(gameState.particles) > getCurrentMaxParticles()) { // Avoid freezing when lots of explosion happen at once count = 1; } for (let i = 0; i < count; i++) { const dx = ((Math.random() - 0.5) * gameState.brickWidth) / 2; const dy = ((Math.random() - 0.5) * gameState.brickWidth) / 2; makeParticle(gameState, x - dx * 10, y - dy * 10, dx, dy, color, false); } } export function explosionAt( gameState: GameState, index: number, x: number, y: number, ball: Ball, ) { const size = 1 + gameState.perks.bigger_explosions; schedulGameSound(gameState, "explode", ball.x, 1); if (index !== -1) { const col = index % gameState.gridSize; const row = Math.floor(index / gameState.gridSize); // Break bricks around for (let dx = -size; dx <= size; dx++) { for (let dy = -size; dy <= size; dy++) { const i = getRowColIndex(gameState, row + dy, col + dx); if (gameState.bricks[i] && i !== -1) { // Study bricks resist explosions too gameState.brickHP[i]--; if (gameState.brickHP[i] <= 0) { explodeBrick(gameState, i, ball, true); } } } } } const factor = gameState.perks.implosions ? -1 : 1; // Blow nearby coins forEachLiveOne(gameState.coins, (c) => { const dx = c.x - x; const dy = c.y - y; const d2 = Math.max(gameState.brickWidth, Math.abs(dx) + Math.abs(dy)); c.vx += (((dx / d2) * 10 * size) / c.weight) * factor; c.vy += (((dy / d2) * 10 * size) / c.weight) * factor; }); gameState.lastExplosion = Date.now(); makeLight(gameState, x, y, "white", gameState.brickWidth * 2, 150); if (gameState.perks.implosions) { spawnImplosion( gameState, 7 * (1 + gameState.perks.bigger_explosions), x, y, "white", ); } else { spawnExplosion( gameState, 7 * (1 + gameState.perks.bigger_explosions), x, y, "white", ); } gameState.runStatistics.bricks_broken++; if (gameState.perks.zen) { resetCombo(gameState, x, y); } } export function explodeBrick( gameState: GameState, index: number, ball: Ball, isExplosion: boolean, ) { const color = gameState.bricks[index]; if (!color) return; if (color === "black" || color === "transparent") { const x = brickCenterX(gameState, index), y = brickCenterY(gameState, index); if (color === "transparent") { schedulGameSound(gameState, "void", x, 1); resetCombo(gameState, x, y); } setBrick(gameState, index, ""); explosionAt(gameState, index, x, y, ball); } else if (color) { // Even if it bounces we don't want to count that as a miss // Flashing is take care of by the tick loop const x = brickCenterX(gameState, index), y = brickCenterY(gameState, index); setBrick(gameState, index, ""); let coinsToSpawn = gameState.combo; if (gameState.perks.sturdy_bricks) { // +10% per level coinsToSpawn += Math.ceil( ((10 + gameState.perks.sturdy_bricks) / 10) * coinsToSpawn, ); } gameState.levelSpawnedCoins += coinsToSpawn; gameState.runStatistics.coins_spawned += coinsToSpawn; gameState.runStatistics.bricks_broken++; const maxCoins = getCurrentMaxCoins() * (isOptionOn("basic") ? 0.5 : 1); const spawnableCoins = liveCount(gameState.coins) > getCurrentMaxCoins() ? 1 : Math.floor(maxCoins - liveCount(gameState.coins)) / 3; const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins)); while (coinsToSpawn > 0) { const points = Math.min(pointsPerCoin, coinsToSpawn); if (points < 0 || isNaN(points)) { console.error({ points }); debugger; } coinsToSpawn -= points; const cx = x + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize), cy = y + (Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize); makeCoin( gameState, cx, cy, ball.previousVX * (0.5 + Math.random()), ball.previousVY * (0.5 + Math.random()), gameState.perks.metamorphosis ? color : "gold", points, ); } gameState.combo += gameState.perks.streak_shots + gameState.perks.compound_interest + gameState.perks.left_is_lava + gameState.perks.right_is_lava + gameState.perks.top_is_lava + gameState.perks.picky_eater + gameState.perks.asceticism + gameState.perks.zen + gameState.perks.passive_income + gameState.perks.nbricks + gameState.perks.unbounded; if (gameState.perks.side_kick) { if (Math.abs(ball.vx) > Math.abs(ball.vy)) { gameState.combo += gameState.perks.side_kick; } else { decreaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y); } } if (gameState.perks.reach) { if ( countBricksAbove(gameState, index) && !countBricksBelow(gameState, index) ) { resetCombo(gameState, x, y); } else { gameState.combo += gameState.perks.reach; } } if ( gameState.lastPuckMove && gameState.perks.passive_income && gameState.lastPuckMove > gameState.levelTime - 250 * gameState.perks.passive_income ) { resetCombo(gameState, x, y); } if ( gameState.perks.nbricks && ball.brokenSinceBounce > gameState.perks.nbricks ) { // We need to reset at each hit, otherwise it's just an OP version of single puck hit streak resetCombo(gameState, ball.x, ball.y); } if (!isExplosion) { // color change if ( (gameState.perks.picky_eater || gameState.perks.pierce_color) && color !== gameState.ballsColor && color ) { if (gameState.perks.picky_eater) { resetCombo(gameState, ball.x, ball.y); } schedulGameSound(gameState, "colorChange", ball.x, 0.8); gameState.lastExplosion = gameState.levelTime; gameState.ballsColor = color; if (!isOptionOn("basic")) { gameState.balls.forEach((ball) => { spawnExplosion(gameState, 7, ball.previousX, ball.previousY, color); }); } } else { schedulGameSound(gameState, "comboIncreaseMaybe", ball.x, 1); } } makeLight(gameState, x, y, color, gameState.brickWidth, 40); spawnExplosion(gameState, 5 + Math.min(gameState.combo, 30), x, y, color); } if (!gameState.bricks[index] && color !== "black") { ball.hitItem?.push({ index, color, }); } } export function dontOfferTooSoon(gameState: GameState, id: PerkId) { gameState.lastOffered[id] = Math.round(Date.now() / 1000); } export function pickRandomUpgrades(gameState: GameState, count: number) { let list = getPossibleUpgrades(gameState) .map((u) => ({ ...u, score: gameState.isAdventureMode ? 0 : Math.random() + (gameState.lastOffered[u.id] || 0), })) .sort((a, b) => a.score - b.score) .filter((u) => gameState.perks[u.id] < u.max) .slice(0, count) .sort((a, b) => (a.id > b.id ? 1 : -1)); list.forEach((u) => { dontOfferTooSoon(gameState, u.id); }); return list.map((u) => ({ text: u.name + (gameState.perks[u.id] ? t("level_up.upgrade_perk_to_level", { level: gameState.perks[u.id] + 1, }) : ""), icon: icons["icon:" + u.id], value: u.id as PerkId, help: u.help(gameState.perks[u.id] + 1), })); } export function schedulGameSound( 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 }; 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; addToTotalScore(gameState, coin.points); if ( gameState.score > gameState.highScore && !gameState.isCreativeModeRun && !gameState.isAdventureMode ) { 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 (coin.points > 0) { schedulGameSound(gameState, "coinCatch", coin.x, 1); } else { resetCombo(gameState, coin.x, coin.y); } gameState.runStatistics.score += coin.points; if (gameState.perks.asceticism) { resetCombo(gameState, coin.x, coin.y); } } export async function setLevel(gameState: GameState, l: number) { // Here to alleviate double upgrades issues if (gameState.upgradesOfferedFor >= l) { debugger; return console.warn("Extra upgrade request ignored "); } gameState.upgradesOfferedFor = l; pause(false); stopRecording(); if (l > 0) { if (gameState.isAdventureMode) { await openAdventureRunUpgradesPicker(gameState); } else { await openShortRunUpgradesPicker(gameState); } } gameState.currentLevel = l; // That list is populated just before if you're in adventure mode gameState.level = gameState.runLevels[l]; gameState.levelTime = 0; gameState.winAt = 0; gameState.levelWallBounces = 0; gameState.autoCleanUses = 0; gameState.lastTickDown = gameState.levelTime; gameState.levelStartScore = gameState.score; gameState.levelSpawnedCoins = 0; gameState.levelMisses = 0; gameState.runStatistics.levelsPlayed++; // Reset combo silently const finalCombo = gameState.combo; gameState.combo = baseCombo(gameState); if (gameState.perks.shunt) { gameState.combo += Math.round( Math.max( 0, ((finalCombo - gameState.combo) * 20 * gameState.perks.shunt) / 100, ), ); } gameState.combo += gameState.perks.hot_start * 15; const lvl = currentLevelInfo(gameState); if (lvl.size !== gameState.gridSize) { gameState.gridSize = lvl.size; fitSize(); } empty(gameState.coins); empty(gameState.particles); empty(gameState.lights); empty(gameState.texts); gameState.bricks = []; for (let i = 0; i < lvl.size * lvl.size; i++) { setBrick(gameState, i, lvl.bricks[i]); } if (gameState.debuffs.negative_bricks) { let attemps = 0; let changed = 0; while (attemps < 100 && changed < gameState.debuffs.negative_bricks) { attemps++; const index = Math.floor(Math.random() * gameState.bricks.length); if ( gameState.bricks[index] && gameState.bricks[index] !== "transparent" ) { gameState.bricks[index] = "transparent"; gameState.brickHP[index] = 1; changed++; } } } // Balls color will depend on most common brick color sometimes resetBalls(gameState); gameState.needsRender = true; // This caused problems with accented characters like the ô of côte d'ivoire for odd reasons // background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg) background.src = "data:image/svg+xml;UTF8," + lvl.svg; } function setBrick(gameState: GameState, index: number, color: string) { gameState.bricks[index] = color || ""; gameState.brickHP[index] = (color === "black" && 1) || (color === "transparent" && 1) || (color && 1 + gameState.perks.sturdy_bricks) || 0; } export function rainbowColor(): colorString { return `hsl(${(Math.round(gameState.levelTime / 4) * 2) % 360},100%,70%)`; } export function repulse( gameState: GameState, a: Ball, b: BallLike, power: number, impactsBToo: boolean, ) { const distance = distanceBetween(a, b); // Ensure we don't get soft locked const max = gameState.gameZoneWidth / 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; 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, 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 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; makeParticle( gameState, a.x, a.y, dx * speed + a.vx + (Math.random() - 0.5) * rand, dy * speed + a.vy + (Math.random() - 0.5) * rand, rainbowColor(), true, gameState.coinSize / 2, 100, ); makeParticle( gameState, b.x, b.y, -dx * speed + b.vx + (Math.random() - 0.5) * rand, -dy * speed + b.vy + (Math.random() - 0.5) * rand, rainbowColor(), true, gameState.coinSize / 2, 100, ); } export function coinBrickHitCheck(gameState: GameState, coin: Coin) { // Make ball/coin bonce, and return bricks that were hit const radius = coin.size / 2; const { x, y, previousX, previousY } = coin; const vhit = hitsSomething(previousX, y, radius); const hhit = hitsSomething(x, previousY, radius); const chit = (typeof vhit == "undefined" && typeof hhit == "undefined" && hitsSomething(x, y, radius)) || undefined; if (!gameState.perks.ghost_coins) { if (typeof vhit !== "undefined" || typeof chit !== "undefined") { coin.y = coin.previousY; coin.vy *= -1; // Roll on corners const leftHit = gameState.bricks[brickIndex(x - radius, y + radius)]; const rightHit = gameState.bricks[brickIndex(x + radius, y + radius)]; 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; } } return vhit ?? hhit ?? chit; } export function bordersHitCheck( 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 (gameState.perks.wind) { coin.vx += ((gameState.puckPosition - (gameState.offsetX + gameState.gameZoneWidth / 2)) / gameState.gameZoneWidth) * gameState.perks.wind * 0.5; } let vhit = 0, hhit = 0; if ( coin.x < gameState.offsetXRoundedDown + radius && !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; } export function gameStateTick( gameState: GameState, // How many frames to compute at once, can go above 1 to compensate lag frames = 1, ) { gameState.runStatistics.max_combo = Math.max( gameState.runStatistics.max_combo, gameState.combo, ); gameState.balls = gameState.balls.filter((ball) => !ball.destroyed); const remainingBricks = gameState.bricks.filter( (b) => b && b !== "black", ).length; if ( gameState.levelTime > gameState.lastTickDown + 1000 && gameState.perks.hot_start ) { gameState.lastTickDown = gameState.levelTime; decreaseCombo( gameState, gameState.perks.hot_start, gameState.puckPosition, gameState.gameZoneHeight - 2 * gameState.puckHeight, ); } if ( remainingBricks <= gameState.perks.skip_last && !gameState.autoCleanUses ) { gameState.bricks.forEach((type, index) => { if (type) { explodeBrick(gameState, index, gameState.balls[0], true); } }); gameState.autoCleanUses++; } const hasPendingBricks = gameState.perks.respawn && gameState.balls.find((b) => b.hitItem.length > 1); if (gameState.running && !remainingBricks && !hasPendingBricks) { if (!gameState.winAt) { gameState.winAt = gameState.levelTime + 5000; } } else { gameState.winAt = 0; } if ( // Delayed win when coins are still flying (gameState.winAt && gameState.levelTime > gameState.winAt) || // instant win condition (gameState.running && gameState.levelTime && !remainingBricks && !liveCount(gameState.coins)) ) { if ( gameState.isAdventureMode || gameState.currentLevel + 1 < max_levels(gameState) ) { if (gameState.running) { setLevel(gameState, gameState.currentLevel + 1); } } else { gameOver( t("gameOver.win.title"), t("gameOver.win.summary", { score: gameState.score }), ); } } else if (gameState.running || gameState.levelTime) { const coinRadius = Math.round(gameState.coinSize / 2); forEachLiveOne(gameState.coins, (coin, coinIndex) => { if (gameState.perks.coin_magnet) { const strength = (100 / (100 + Math.pow(coin.y - gameState.gameZoneHeight, 2) + Math.pow(coin.x - gameState.puckPosition, 2))) * gameState.perks.coin_magnet; 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, coin.color, true, 5, 250, ); } } const speed = (Math.abs(coin.vx) + Math.abs(coin.vy)) * 10; const hitBorder = bordersHitCheck(gameState, coin, coin.size / 2, frames); if ( coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight && coin.y < gameState.gameZoneHeight + gameState.puckHeight + coin.vy && Math.abs(coin.x - gameState.puckPosition) < coinRadius + gameState.puckWidth / 2 + // a bit of margin to be nice , negative in case it's a negative coin gameState.puckHeight * (coin.points ? 1 : -1) ) { addToScore(gameState, coin); destroy(gameState.coins, coinIndex); } else if (coin.y > gameState.canvasHeight + coinRadius) { destroy(gameState.coins, coinIndex); if (gameState.perks.compound_interest) { resetCombo(gameState, coin.x, coin.y); } } else if ( gameState.perks.unbounded && (coin.x < -gameState.gameZoneWidth / 2 || coin.x > gameState.canvasWidth + gameState.gameZoneWidth / 2) ) { // Out of bound on sides destroy(gameState.coins, coinIndex); } const hitBrick = coinBrickHitCheck(gameState, coin); if ( gameState.debuffs.void_coins_on_touch && coin.points && typeof hitBrick !== "undefined" && gameState.bricks[hitBrick] == "transparent" ) { coin.points = 0; coin.color = "transparent"; schedulGameSound(gameState, "void", coin.x, 1); } else if ( gameState.debuffs.void_brick_on_touch && !coin.points && typeof hitBrick !== "undefined" && gameState.bricks[hitBrick] !== "transparent" ) { setBrick(gameState, hitBrick, "transparent"); schedulGameSound(gameState, "void", coin.x, 1); } else if ( gameState.perks.metamorphosis && typeof hitBrick !== "undefined" ) { if ( gameState.bricks[hitBrick] && coin.color !== gameState.bricks[hitBrick] && gameState.bricks[hitBrick] !== "black" && !coin.coloredABrick ) { // Not using setbrick because we don't want to reset HP gameState.bricks[hitBrick] = coin.color; coin.coloredABrick = true; schedulGameSound(gameState, "colorChange", coin.x, 0.3); } } if ( (!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") || hitBorder ) { coin.vx *= 0.8; coin.vy *= 0.8; coin.sa *= 0.9; if (speed > 20) { schedulGameSound(gameState, "coinBounce", coin.x, 0.2); } if (Math.abs(coin.vy) < 3) { coin.vy = 0; } } }); gameState.balls.forEach((ball) => ballTick(gameState, ball, frames)); if ( !isOptionOn("basic") && gameState.debuffs.downward_wind && gameState.levelTime / 1000 < gameState.debuffs.downward_wind ) { makeParticle( gameState, gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, gameState.gameZoneHeight * Math.random(), 0, gameState.baseSpeed, "red", true, gameState.coinSize / 2, 150, ); } if (gameState.debuffs.side_wind) { const dir = (gameState.currentLevel % 2 ? -1 : 1) * gameState.debuffs.side_wind * gameState.baseSpeed; gameState.balls.forEach((ball) => { ball.vx += dir / 2000; }); forEachLiveOne(gameState.coins, (c) => (c.vx += dir / 100)); if (!isOptionOn("basic")) makeParticle( gameState, gameState.offsetXRoundedDown + Math.random() * gameState.gameZoneWidthRoundedUp, gameState.gameZoneHeight * Math.random(), dir / 3, 0, "red", true, gameState.coinSize / 2, 150, ); } 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), ); } 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; let speedLimitDampener = 1 + gameState.perks.telekinesis + gameState.perks.ball_repulse_ball + gameState.perks.puck_repulse_ball + gameState.perks.ball_attract_ball; if ( gameState.debuffs.downward_wind && gameState.levelTime / 1000 < gameState.debuffs.downward_wind ) { ball.vy += gameState.baseSpeed / 50; speedLimitDampener += 10; } if (isTelekinesisActive(gameState, ball)) { speedLimitDampener += 3; ball.vx += ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.telekinesis; } if (isYoyoActive(gameState, ball)) { speedLimitDampener += 3; ball.vx += ((gameState.puckPosition - ball.x) / 1000) * delta * gameState.perks.yoyo; } if ( ball.vx * ball.vx + ball.vy * ball.vy < gameState.baseSpeed * gameState.baseSpeed * 2 ) { ball.vx *= 1 + 0.02 / speedLimitDampener; ball.vy *= 1 + 0.02 / speedLimitDampener; } else { ball.vx *= 1 - 0.02 / speedLimitDampener; ball.vy *= 1 - 0.02 / speedLimitDampener; } // Ball could get stuck horizontally because of ball-ball interactions in repulse/attract if (Math.abs(ball.vy) < 0.2 * gameState.baseSpeed) { ball.vy += ((ball.vy > 0 ? 1 : -1) * 0.02) / speedLimitDampener; } if (gameState.perks.ball_repulse_ball) { for (let b2 of gameState.balls) { // avoid computing this twice, and repulsing itself if (b2.x >= ball.x) continue; repulse(gameState, ball, b2, gameState.perks.ball_repulse_ball, true); } } if (gameState.perks.ball_attract_ball) { for (let b2 of gameState.balls) { // avoid computing this twice, and repulsing itself if (b2.x >= ball.x) continue; attract(gameState, ball, b2, gameState.perks.ball_attract_ball); } } if ( gameState.perks.puck_repulse_ball && Math.abs(ball.x - gameState.puckPosition) < gameState.puckWidth / 2 + (gameState.ballSize * (9 + gameState.perks.puck_repulse_ball)) / 10 ) { repulse( gameState, ball, { x: gameState.puckPosition, y: gameState.gameZoneHeight, }, gameState.perks.puck_repulse_ball + 1, false, ); } if ( gameState.perks.respawn && ball.hitItem?.length > 1 && !isOptionOn("basic") ) { for ( let i = 0; i < ball.hitItem?.length - 1 && i < gameState.perks.respawn; i++ ) { const { index, color } = ball.hitItem[i]; if (gameState.bricks[index] || color === "black") continue; const vertical = Math.random() > 0.5; const dx = Math.random() > 0.5 ? 1 : -1; const dy = Math.random() > 0.5 ? 1 : -1; 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.right_is_lava && borderHitCode % 2 && ball.x > gameState.offsetX + gameState.gameZoneWidth / 2 ) { resetCombo(gameState, ball.x, ball.y); } if (gameState.perks.top_is_lava && borderHitCode >= 2) { resetCombo(gameState, ball.x, ball.y + gameState.ballSize); } if (gameState.perks.trampoline && borderHitCode >= 2) { decreaseCombo( gameState, gameState.perks.trampoline, ball.x, ball.y + gameState.ballSize, ); } schedulGameSound(gameState, "wallBeep", ball.x, 1); gameState.levelWallBounces++; gameState.runStatistics.wall_bounces++; } // Puck collision const ylimit = gameState.gameZoneHeight - gameState.puckHeight - gameState.ballSize / 2; const ballIsUnderPuck = Math.abs(ball.x - gameState.puckPosition) < gameState.ballSize / 2 + gameState.puckWidth / 2; if ( ball.y > ylimit && ball.vy > 0 && (ballIsUnderPuck || (gameState.perks.extra_life && ball.y > ylimit + gameState.puckHeight / 2)) ) { if (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) { gameState.bricks.forEach( (color, index) => color && explodeBrick(gameState, index, ball, true), ); } schedulGameSound(gameState, "lifeLost", ball.x, 1); if (!isOptionOn("basic")) { for (let i = 0; i < 10; i++) makeParticle( gameState, ball.x, ball.y, Math.random() * gameState.baseSpeed * 3, gameState.baseSpeed * 3, "red", false, gameState.coinSize / 2, 150, ); } } if (gameState.perks.streak_shots) { resetCombo(gameState, ball.x, ball.y); } if (gameState.perks.trampoline) { gameState.combo += gameState.perks.trampoline; } if ( gameState.perks.nbricks && ball.brokenSinceBounce < gameState.perks.nbricks ) { resetCombo(gameState, ball.x, ball.y); } if (gameState.perks.respawn) { ball.hitItem .slice(0, -1) .slice(0, gameState.perks.respawn) .forEach(({ index, color }) => { if (!gameState.bricks[index] && color !== "black") { // respawns with full hp setBrick(gameState, index, color); } // gameState.bricks[index] = color; }); } ball.hitItem = []; if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) { gameState.runStatistics.misses++; if (gameState.perks.forgiving) { const loss = Math.floor( (gameState.levelMisses / 10) * (gameState.combo - baseCombo(gameState)), ); decreaseCombo(gameState, loss, ball.x, ball.y - gameState.ballSize); } else { resetCombo(gameState, ball.x, ball.y); } gameState.levelMisses++; makeText( gameState, gameState.puckPosition, gameState.gameZoneHeight - gameState.puckHeight * 2, "red", t("play.missed_ball"), gameState.puckHeight, 500, ); } gameState.runStatistics.puck_bounces++; ball.hitSinceBounce = 0; ball.brokenSinceBounce = 0; ball.sapperUses = 0; ball.piercePoints = gameState.perks.pierce * 3; } const lostOnSides = (gameState.perks.unbounded && ball.x < -gameState.gameZoneWidth / 2) || ball.x > gameState.canvasWidth + gameState.gameZoneWidth / 2; if ( gameState.running && (ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 || lostOnSides) ) { ball.destroyed = true; gameState.runStatistics.balls_lost++; if (!gameState.balls.find((b) => !b.destroyed)) { gameOver( t("gameOver.lost.title"), t("gameOver.lost.summary", { score: gameState.score }), ); } } const radius = gameState.ballSize / 2; // Make ball/coin bonce, and return bricks that were hit const { x, y, previousX, previousY } = ball; const vhit = hitsSomething(previousX, y, radius); const hhit = hitsSomething(x, previousY, radius); const chit = (typeof vhit == "undefined" && typeof hhit == "undefined" && hitsSomething(x, y, radius)) || undefined; const hitBrick = vhit ?? hhit ?? chit; if (typeof hitBrick !== "undefined") { ball.hitSinceBounce++; let pierce = false; let damage = 1 + (shouldPierceByColor(gameState, vhit, hhit, chit) ? gameState.perks.pierce_color : 0); gameState.brickHP[hitBrick] -= damage; const used = Math.min( ball.piercePoints, Math.max(1, gameState.brickHP[hitBrick]), ); gameState.brickHP[hitBrick] -= used; ball.piercePoints -= used; if (gameState.brickHP[hitBrick] < 0) { gameState.brickHP[hitBrick] = 0; pierce = true; } if (typeof vhit !== "undefined" || typeof chit !== "undefined") { if (!pierce) { ball.y = ball.previousY; ball.vy *= -1; } } if (typeof hhit !== "undefined" || typeof chit !== "undefined") { if (!pierce) { ball.x = ball.previousX; ball.vx *= -1; } } if (!gameState.brickHP[hitBrick]) { const initialBrickColor = gameState.bricks[hitBrick]; ball.brokenSinceBounce++; explodeBrick(gameState, hitBrick, ball, false); if ( ball.sapperUses < gameState.perks.sapper && initialBrickColor !== "black" && // don't replace a brick that bounced with sturdy_bricks !gameState.bricks[hitBrick] ) { setBrick(gameState, hitBrick, "black"); ball.sapperUses++; } } } if (!isOptionOn("basic")) { const remainingPierce = ball.piercePoints; const remainingSapper = ball.sapperUses < gameState.perks.sapper; const extraCombo = gameState.combo - 1; if ( (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) || (remainingSapper && Math.random() > 0.1 / (1 + remainingSapper)) || (extraCombo && Math.random() > 0.1 / (1 + extraCombo)) ) { const color = remainingSapper ? Math.random() > 0.5 ? "orange" : "red" : gameState.ballsColor; makeParticle( gameState, ball.x, ball.y, gameState.perks.pierce_color || remainingPierce ? -ball.vx + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 : (Math.random() - 0.5) * gameState.baseSpeed, gameState.perks.pierce_color || remainingPierce ? -ball.vy + ((Math.random() - 0.5) * gameState.baseSpeed) / 3 : (Math.random() - 0.5) * gameState.baseSpeed, color, true, gameState.coinSize / 2, 100, ); } } } function makeCoin( gameState: GameState, x: number, y: number, vx: number, vy: number, color = "gold", points = 1, ) { if (gameState.debuffs.negative_coins > Math.random() * 100) { points = 0; color = "transparent"; } 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, ) { 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, ) { 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, ) { 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, ) { 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--; } export function liveCount(where: ReusableArray) { return where.total; } export function empty(where: ReusableArray) { 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.list.forEach((item: T, index: number) => { if (item && !item.destroyed) { cb(item, index); } }); }