breakout71/src/gameStateMutators.ts

2187 lines
68 KiB
TypeScript
Raw Normal View History

2025-03-18 14:16:12 +01:00
import {
2025-04-20 21:14:35 +02:00
Ball,
BallLike,
Coin,
colorString,
GameState,
LightFlash,
ParticleFlash,
PerkId,
ReusableArray,
TextFlash,
2025-03-18 14:16:12 +01:00
} from "./types";
import {
2025-04-20 21:14:35 +02:00
ballTransparency,
brickCenterX,
brickCenterY,
currentLevelInfo,
distance2,
distanceBetween,
getClosestBall,
getCoinRenderColor,
getCornerOffset,
getMajorityValue,
getPossibleUpgrades,
getRowColIndex,
isMovingWhilePassiveIncome,
isPickyEatingPossible,
max_levels,
reachRedRowIndex,
shouldPierceByColor,
telekinesisEffectRate,
yoyoEffectRate,
} from "./game_utils";
2025-04-20 21:14:35 +02:00
import {t} from "./i18n/i18n";
import {icons} from "./loadGameData";
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
import {getCurrentMaxCoins, getCurrentMaxParticles} from "./settings";
import {background} from "./render";
import {gameOver} from "./gameOver";
2025-04-11 20:34:51 +02:00
import {
2025-04-20 21:14:35 +02:00
brickIndex,
fitSize,
gameState,
hasBrick,
hitsSomething,
openUpgradesPicker,
pause,
startComputerControlledGame,
2025-04-11 20:34:51 +02:00
} from "./game";
2025-04-20 21:14:35 +02:00
import {stopRecording} from "./recording";
import {isOptionOn} from "./options";
import {clamp, comboKeepingRate} from "./pure_functions";
import {addToTotalScore} from "./addToTotalScore";
import {hashCode} from "./getLevelBackground";
export function setMousePos(gameState: GameState, x: number) {
2025-04-20 21:14:35 +02:00
if (gameState.startParams.computer_controlled) return;
gameState.puckPosition = x;
2025-04-12 09:24:07 +02:00
2025-04-20 21:14:35 +02:00
// Sets the puck position, and updates the ball position if they are supposed to follow it
gameState.needsRender = true;
}
2025-03-16 17:45:29 +01:00
function getBallDefaultVx(gameState: GameState) {
2025-04-20 21:14:35 +02:00
return (
(gameState.perks.concave_puck ? 0 : 1) *
(Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed)
);
2025-03-16 17:45:29 +01:00
}
2025-03-16 20:11:08 +01:00
2025-04-12 20:01:43 +02:00
function computerControl(gameState: GameState) {
2025-04-20 21:14:35 +02:00
let targetX = gameState.puckPosition;
const ball = getClosestBall(
gameState,
gameState.puckPosition,
gameState.gameZoneHeight,
);
if (!ball) return;
const puckOffset =
(((hashCode(gameState.runStatistics.puck_bounces + "goeirjgoriejg") % 100) -
50) /
100) *
gameState.puckWidth;
if (ball.y > gameState.gameZoneHeight / 2 && ball.vy > 0) {
targetX = ball.x + puckOffset;
2025-04-12 20:01:43 +02:00
} else {
2025-04-20 21:14:35 +02:00
let coinsTotalX = 0,
coinsCount = 0;
forEachLiveOne(gameState.coins, (c) => {
if (c.vy > 0 && c.y > gameState.gameZoneHeight / 2) {
coinsTotalX += c.x;
coinsCount++;
}
});
if (coinsCount) {
targetX = coinsTotalX / coinsCount;
} else {
targetX = gameState.canvasWidth / 2;
}
}
gameState.puckPosition += clamp(
(targetX - gameState.puckPosition) / 10,
-10,
10,
);
if (gameState.levelTime > 30000) {
startComputerControlledGame(gameState.startParams.stress);
2025-04-12 20:01:43 +02:00
}
}
export function resetBalls(gameState: GameState) {
2025-04-20 21:14:35 +02:00
// Always compute speed first
normalizeGameState(gameState);
const count = 1 + (gameState.perks?.multiball || 0);
const perBall = gameState.puckWidth / (count + 1);
gameState.balls = [];
gameState.ballsColor = "#FFFFFF";
if (gameState.perks.picky_eater || gameState.perks.pierce_color) {
gameState.ballsColor =
getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFFFFF";
}
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,
piercePoints: gameState.perks.pierce * 3,
hitSinceBounce: 0,
brokenSinceBounce: 0,
2025-04-21 09:06:15 +02:00
sidesHitsSinceBounce: 0,
2025-04-20 21:14:35 +02:00
sapperUses: 0,
});
}
gameState.ballStickToPuck = true;
}
export function putBallsAtPuck(gameState: GameState) {
2025-04-20 21:14:35 +02:00
// 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.hitSinceBounce = 0;
ball.brokenSinceBounce = 0;
2025-04-21 09:06:15 +02:00
ball.sidesHitsSinceBounce = 0;
2025-04-20 21:14:35 +02:00
ball.piercePoints = gameState.perks.pierce * 3;
});
}
export function normalizeGameState(gameState: GameState) {
2025-04-20 21:14:35 +02:00
// This function resets most parameters on the state to correct values, and should be used even when the game is paused
gameState.baseSpeed = Math.max(
3,
gameState.gameZoneWidth / 12 / 10 +
gameState.currentLevel / 3 +
gameState.levelTime / (30 * 1000) -
gameState.perks.slow_down * 2,
);
gameState.puckWidth = Math.max(
gameState.ballSize,
(gameState.gameZoneWidth / 12) *
Math.min(
12,
3 - gameState.perks.smaller_puck + gameState.perks.bigger_puck,
),
);
const corner = getCornerOffset(gameState);
let minX = gameState.offsetXRoundedDown + gameState.puckWidth / 2 - corner;
let maxX =
gameState.offsetXRoundedDown +
gameState.gameZoneWidthRoundedUp -
gameState.puckWidth / 2 +
corner;
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) {
2025-04-20 21:14:35 +02:00
const mineFieldBonus =
gameState.perks.minefield &&
gameState.bricks.filter((b) => b === "black").length *
gameState.perks.minefield;
return (
1 +
gameState.perks.base_combo * 3 +
gameState.perks.smaller_puck * 5 +
mineFieldBonus
);
}
export function resetCombo(
2025-04-20 21:14:35 +02:00
gameState: GameState,
x: number | undefined,
y: number | undefined,
) {
2025-04-20 21:14:35 +02:00
const prev = gameState.combo;
gameState.combo = baseCombo(gameState);
2025-03-19 14:06:49 +01:00
2025-04-20 21:14:35 +02:00
if (prev > gameState.combo && gameState.perks.soft_reset) {
gameState.combo += Math.floor(
(prev - gameState.combo) * comboKeepingRate(gameState.perks.soft_reset),
);
}
2025-04-20 21:14:35 +02:00
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,
"#FF0000",
"-" + lost,
20,
500 + clamp(lost, 0, 500),
);
}
}
2025-04-20 21:14:35 +02:00
return lost;
}
2025-04-02 19:50:05 +02:00
export function increaseCombo(
2025-04-20 21:14:35 +02:00
gameState: GameState,
by: number,
x: number,
y: number,
2025-04-02 19:50:05 +02:00
) {
2025-04-20 21:14:35 +02:00
if (by <= 0) {
return;
}
gameState.combo += by;
if (
isOptionOn("comboIncreaseTexts") &&
typeof x !== "undefined" &&
typeof y !== "undefined"
) {
makeText(gameState, x, y, "#ffd300", "+" + by, 25, 400 + by);
}
2025-04-02 19:50:05 +02:00
}
2025-04-08 08:57:41 +02:00
export function decreaseCombo(
2025-04-20 21:14:35 +02:00
gameState: GameState,
by: number,
x: number,
y: number,
) {
2025-04-20 21:14:35 +02:00
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, "#FF0000", "-" + lost, 20, 400 + lost);
}
}
}
export function spawnExplosion(
2025-04-20 21:14:35 +02:00
gameState: GameState,
count: number,
x: number,
y: number,
color: string,
) {
2025-04-20 21:14:35 +02:00
if (!!isOptionOn("basic")) return;
2025-03-29 15:00:44 +01:00
2025-04-20 21:14:35 +02:00
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(
2025-04-20 21:14:35 +02:00
gameState: GameState,
count: number,
x: number,
y: number,
color: string,
) {
2025-04-20 21:14:35 +02:00
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);
}
}
2025-03-19 21:58:08 +01:00
export function explosionAt(
2025-04-20 21:14:35 +02:00
gameState: GameState,
index: number,
x: number,
y: number,
ball: Ball,
extraSize: number = 0,
2025-03-19 21:58:50 +01:00
) {
2025-04-20 21:14:35 +02:00
const size =
1 +
gameState.perks.bigger_explosions +
Math.max(0, gameState.perks.implosions - 1) +
extraSize;
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);
}
}
}
}
2025-03-18 14:16:12 +01:00
}
2025-04-20 21:14:35 +02:00
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();
if (gameState.perks.implosions) {
spawnImplosion(gameState, 7 * size, x, y, "#FFFFFF");
} else {
spawnExplosion(gameState, 7 * size, x, y, "#FFFFFF");
}
gameState.runStatistics.bricks_broken++;
if (gameState.perks.zen) {
resetCombo(gameState, x, y);
}
2025-04-09 11:28:32 +02:00
}
2025-04-09 11:28:32 +02:00
export function explodeBrick(
2025-04-20 21:14:35 +02:00
gameState: GameState,
index: number,
ball: Ball,
isExplosion: boolean,
2025-04-09 11:28:32 +02:00
) {
2025-04-20 21:14:35 +02:00
const color = gameState.bricks[index];
if (!color) return;
const wasPickyEaterPossible =
gameState.perks.picky_eater && isPickyEatingPossible(gameState);
const redRowReach = reachRedRowIndex(gameState);
gameState.lastBrickBroken = gameState.levelTime;
if (color === "black") {
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, 0);
if (gameState.perks.minefield) {
decreaseCombo(gameState, gameState.perks.minefield, x, y);
}
} else if (color) {
// Even if it bounces we don't want to count that as a miss
2025-04-20 21:14:35 +02:00
// Flashing is take care of by the tick loop
const x = brickCenterX(gameState, index),
y = brickCenterY(gameState, index);
2025-03-19 21:58:50 +01:00
2025-04-20 21:14:35 +02:00
setBrick(gameState, index, "");
2025-04-10 21:40:45 +02:00
2025-04-20 21:14:35 +02:00
let coinsToSpawn = gameState.combo;
if (gameState.lastCombo > coinsToSpawn) {
// In case a reset happens in the same frame as a spawn, i want the combo to stay high (for minefield and zen in particular)
coinsToSpawn = gameState.lastCombo;
}
if (gameState.perks.sturdy_bricks) {
// +10% per level
coinsToSpawn += Math.ceil(
((2 + gameState.perks.sturdy_bricks) / 2) * coinsToSpawn,
);
}
if (gameState.perks.transparency) {
coinsToSpawn = Math.ceil(
coinsToSpawn *
(1 +
(ballTransparency(ball, gameState) * gameState.perks.transparency) /
2),
);
}
2025-04-09 11:28:32 +02:00
2025-04-20 21:14:35 +02:00
gameState.levelSpawnedCoins += coinsToSpawn;
gameState.runStatistics.coins_spawned += coinsToSpawn;
gameState.runStatistics.bricks_broken++;
2025-04-09 11:28:32 +02:00
2025-04-20 21:14:35 +02:00
const maxCoins = getCurrentMaxCoins();
const spawnableCoins =
liveCount(gameState.coins) > getCurrentMaxCoins()
? 1
: Math.floor((maxCoins - liveCount(gameState.coins)) / 2);
2025-03-28 11:58:58 +01:00
2025-04-20 21:14:35 +02:00
const pointsPerCoin = Math.max(1, Math.ceil(coinsToSpawn / spawnableCoins));
2025-04-20 21:14:35 +02:00
while (coinsToSpawn > 0) {
const points = Math.min(pointsPerCoin, coinsToSpawn);
if (points < 0 || isNaN(points)) {
console.error({points});
debugger;
}
2025-04-10 21:40:45 +02:00
2025-04-20 21:14:35 +02:00
coinsToSpawn -= points;
2025-03-29 21:28:05 +01:00
2025-04-20 21:14:35 +02:00
const cx =
x +
(Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize),
cy =
y +
(Math.random() - 0.5) * (gameState.brickWidth - gameState.coinSize);
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
makeCoin(
gameState,
cx,
cy,
ball.previousVX * (0.5 + Math.random()),
ball.previousVY * (0.5 + Math.random()),
color,
points,
);
}
2025-04-20 21:14:35 +02:00
increaseCombo(
2025-04-08 08:57:41 +02:00
gameState,
2025-04-20 21:14:35 +02:00
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 * 3 +
gameState.perks.zen +
gameState.perks.passive_income +
gameState.perks.addiction,
2025-04-08 08:57:41 +02:00
ball.x,
ball.y,
2025-04-20 21:14:35 +02:00
);
if (Math.abs(ball.y - y) < Math.abs(ball.x - x)) {
if (gameState.perks.side_kick) {
if (ball.previousVX > 0) {
increaseCombo(gameState, gameState.perks.side_kick, ball.x, ball.y);
} else {
decreaseCombo(
gameState,
gameState.perks.side_kick * 2,
ball.x,
ball.y,
);
}
}
if (gameState.perks.side_flip) {
if (ball.previousVX < 0) {
increaseCombo(gameState, gameState.perks.side_flip, ball.x, ball.y);
} else {
decreaseCombo(
gameState,
gameState.perks.side_flip * 2,
ball.x,
ball.y,
);
}
}
}
2025-04-20 21:14:35 +02:00
if (redRowReach !== -1) {
if (Math.floor(index / gameState.level.size) === redRowReach) {
resetCombo(gameState, x, y);
} else {
for (let x = 0; x < gameState.level.size; x++) {
if (gameState.bricks[redRowReach * gameState.level.size + x])
gameState.combo++;
}
}
}
2025-04-20 21:14:35 +02:00
if (isMovingWhilePassiveIncome(gameState)) {
resetCombo(gameState, x, y);
2025-04-11 20:34:11 +02:00
}
2025-04-20 21:14:35 +02:00
if (!isExplosion) {
// color change
if (
(gameState.perks.picky_eater || gameState.perks.pierce_color) &&
color !== gameState.ballsColor &&
color
) {
if (wasPickyEaterPossible) {
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);
2025-03-29 21:28:05 +01:00
}
2025-04-20 21:14:35 +02:00
if (
gameState.perks.respawn &&
color !== "black" &&
!gameState.bricks[index]
) {
if (Math.random() < comboKeepingRate(gameState.perks.respawn)) {
append(gameState.respawns, (b) => {
b.color = color;
b.index = index;
b.time = gameState.levelTime + (3 * 1000) / gameState.perks.respawn;
});
}
2025-04-09 11:28:32 +02:00
}
}
export function dontOfferTooSoon(gameState: GameState, id: PerkId) {
2025-04-20 21:14:35 +02:00
gameState.lastOffered[id] = Math.round(Date.now() / 1000);
}
export function pickRandomUpgrades(gameState: GameState, count: number) {
2025-04-20 21:14:35 +02:00
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 + gameState.perks.limitless)
.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),
}));
}
2025-03-18 14:16:12 +01:00
export function schedulGameSound(
2025-04-20 21:14:35 +02:00
gameState: GameState,
sound: keyof GameState["aboutToPlaySound"],
x: number | void,
vol: number,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
if (!vol) return;
if (!isOptionOn("sound")) return;
2025-04-18 21:17:32 +02:00
2025-04-20 21:14:35 +02:00
x ??= gameState.offsetX + gameState.gameZoneWidth / 2;
const ex = gameState.aboutToPlaySound[sound] as { vol: number; x: number };
2025-04-20 21:14:35 +02:00
ex.x = (x * vol + ex.x * ex.vol) / (vol + ex.vol);
ex.vol += vol;
}
export function addToScore(gameState: GameState, coin: Coin) {
2025-04-20 21:14:35 +02:00
gameState.score += coin.points;
gameState.lastScoreIncrease = gameState.levelTime;
addToTotalScore(gameState, coin.points);
if (gameState.score > gameState.highScore && !gameState.creative) {
gameState.highScore = gameState.score;
try {
localStorage.setItem("breakout-3-hs-short", gameState.score.toString());
} catch (e) {
2025-04-20 17:32:05 +02:00
2025-04-20 21:14:35 +02:00
}
}
if (!isOptionOn("basic")) {
makeParticle(
gameState,
coin.previousX,
coin.previousY,
(gameState.canvasWidth - coin.x) / 100,
-coin.y / 100,
getCoinRenderColor(gameState, coin),
true,
gameState.coinSize / 2,
100 + Math.random() * 50,
);
}
schedulGameSound(gameState, "coinCatch", coin.x, 1);
gameState.runStatistics.score += coin.points;
if (gameState.perks.asceticism) {
decreaseCombo(
gameState,
gameState.perks.asceticism * 3 * coin.points,
coin.x,
coin.y,
);
2025-04-20 17:32:05 +02:00
}
}
2025-03-19 14:06:49 +01:00
export async function setLevel(gameState: GameState, l: number) {
2025-04-20 21:14:35 +02:00
// 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) {
await openUpgradesPicker(gameState);
}
gameState.currentLevel = l;
gameState.level = gameState.runLevels[l % gameState.runLevels.length];
gameState.levelTime = 0;
gameState.winAt = 0;
gameState.levelWallBounces = 0;
gameState.lastPuckMove = 0;
gameState.autoCleanUses = 0;
gameState.lastTickDown = gameState.levelTime;
gameState.levelStartScore = gameState.score;
gameState.levelSpawnedCoins = 0;
gameState.levelLostCoins = 0;
gameState.levelMisses = 0;
gameState.lastBrickBroken = 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) *
comboKeepingRate(gameState.perks.shunt),
),
);
}
gameState.combo += gameState.perks.hot_start * 30;
const lvl = currentLevelInfo(gameState);
if (lvl.size !== gameState.gridSize) {
gameState.gridSize = lvl.size;
fitSize(gameState);
}
gameState.levelLostCoins += empty(gameState.coins);
empty(gameState.particles);
empty(gameState.lights);
empty(gameState.texts);
empty(gameState.respawns);
gameState.bricks = [];
for (let i = 0; i < lvl.size * lvl.size; i++) {
setBrick(gameState, i, lvl.bricks[i]);
}
// Balls color will depend on most common brick color sometimes
resetBalls(gameState);
gameState.needsRender = true;
// This caused problems with accented characters like the ô of côte d'ivoire for odd reasons
// background.src = 'data:image/svg+xml;base64,' + btoa(lvl.svg)
background.src = "data:image/svg+xml;UTF8," + lvl.svg;
document.body.style.setProperty("--level-background", lvl.color || "#000000");
document
.getElementById("themeColor")
?.setAttribute("content", lvl.color || "#000000");
}
2025-03-23 22:19:28 +01:00
function setBrick(gameState: GameState, index: number, color: string) {
2025-04-20 21:14:35 +02:00
gameState.bricks[index] = color || "";
gameState.brickHP[index] =
(color === "black" && 1) ||
(color && 1 + gameState.perks.sturdy_bricks) ||
0;
if (gameState.perks.minefield && color === "black") {
increaseCombo(
gameState,
gameState.perks.minefield,
brickCenterX(gameState, index),
brickCenterY(gameState, index),
);
}
}
2025-04-06 15:38:30 +02:00
const rainbow = [
2025-04-20 21:14:35 +02:00
"#ff2e2e",
"#ffe02e",
"#70ff33",
"#33ffa7",
"#38acff",
"#7038ff",
"#ff3de5",
2025-04-06 15:38:30 +02:00
];
2025-04-08 08:57:41 +02:00
export function rainbowColor(): colorString {
2025-04-20 21:14:35 +02:00
return rainbow[Math.floor(gameState.levelTime / 50) % rainbow.length];
}
2025-03-16 17:45:29 +01:00
export function repulse(
2025-04-20 21:14:35 +02:00
gameState: GameState,
a: Ball,
b: BallLike,
power: number,
impactsBToo: boolean,
) {
2025-04-20 21:14:35 +02:00
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;
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
const speed = 10;
const rand = 2;
2025-03-18 14:16:12 +01:00
makeParticle(
2025-04-20 21:14:35 +02:00
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,
2025-03-18 14:16:12 +01:00
);
2025-04-20 21:14:35 +02:00
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,
);
}
}
2025-03-16 17:45:29 +01:00
export function attract(gameState: GameState, a: Ball, b: Ball, power: number) {
2025-04-20 21:14:35 +02:00
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,
);
2025-03-16 17:45:29 +01:00
}
2025-03-19 20:14:55 +01:00
export function coinBrickHitCheck(gameState: GameState, coin: Coin) {
2025-04-20 21:14:35 +02:00
// 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) {
// slow down
if (typeof (vhit ?? hhit ?? chit) !== "undefined") {
coin.vy *= 1 - 0.2 / gameState.perks.ghost_coins;
coin.vx *= 1 - 0.2 / gameState.perks.ghost_coins;
}
} else {
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;
}
2025-04-09 11:28:32 +02:00
}
2025-04-20 21:14:35 +02:00
return vhit ?? hhit ?? chit;
2025-03-19 20:14:55 +01:00
}
export function bordersHitCheck(
2025-04-20 21:14:35 +02:00
gameState: GameState,
coin: Coin | Ball,
radius: number,
delta: number,
2025-03-19 20:14:55 +01:00
) {
2025-04-20 21:14:35 +02:00
if (coin.destroyed) return;
coin.previousX = coin.x;
coin.previousY = coin.y;
coin.x += coin.vx * delta;
coin.y += coin.vy * delta;
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;
2025-04-21 09:06:15 +02:00
if (coin.x < gameState.offsetXRoundedDown + radius && gameState.perks.left_is_lava < 2) {
2025-04-20 21:14:35 +02:00
coin.x =
gameState.offsetXRoundedDown +
radius +
(gameState.offsetXRoundedDown + radius - coin.x);
coin.vx *= -1;
hhit = 1;
}
2025-04-21 09:06:15 +02:00
if (coin.y < radius && gameState.perks.top_is_lava < 2) {
2025-04-20 21:14:35 +02:00
coin.y = radius + (radius - coin.y);
coin.vy *= -1;
vhit = 1;
}
2025-04-21 09:06:15 +02:00
if (coin.x > gameState.canvasWidth - gameState.offsetXRoundedDown - radius && gameState.perks.right_is_lava < 2) {
2025-04-20 21:14:35 +02:00
coin.x =
gameState.canvasWidth -
gameState.offsetXRoundedDown -
radius -
(coin.x -
(gameState.canvasWidth - gameState.offsetXRoundedDown - radius));
coin.vx *= -1;
hhit = 1;
}
return hhit + vhit * 2;
}
2025-03-18 14:16:12 +01:00
export function gameStateTick(
2025-04-20 21:14:35 +02:00
gameState: GameState,
// How many frames to compute at once, can go above 1 to compensate lag
frames = 1,
) {
2025-04-20 21:14:35 +02:00
// Ai movement of puck
if (gameState.startParams.computer_controlled) computerControl(gameState);
gameState.runStatistics.max_combo = Math.max(
gameState.runStatistics.max_combo,
gameState.combo,
);
2025-03-19 21:58:50 +01:00
2025-04-20 21:14:35 +02:00
gameState.lastCombo = gameState.combo;
2025-03-29 15:00:44 +01:00
2025-04-20 21:14:35 +02:00
if (
gameState.perks.addiction &&
gameState.lastBrickBroken &&
gameState.lastBrickBroken <
gameState.levelTime - 5000 / gameState.perks.addiction
) {
resetCombo(
gameState,
gameState.puckPosition,
gameState.gameZoneHeight - gameState.puckHeight * 2,
);
}
2025-03-29 15:00:44 +01:00
2025-04-20 21:14:35 +02:00
gameState.balls = gameState.balls.filter((ball) => !ball.destroyed);
const remainingBricks = gameState.bricks.filter(
(b) => b && b !== "black",
).length;
2025-03-29 15:00:44 +01:00
2025-04-20 21:14:35 +02:00
if (!remainingBricks && gameState.lastBrickBroken) {
// Avoid a combo reset just because we're waiting for coins
gameState.lastBrickBroken = 0;
}
2025-04-21 09:06:15 +02:00
if (gameState.perks.hot_start) {
if (gameState.combo === baseCombo(gameState)) {
2025-04-20 21:14:35 +02:00
// Give 1s of time between catching a coin and tick down
2025-04-21 09:06:15 +02:00
gameState.lastTickDown = gameState.levelTime
} else if (
2025-04-20 21:14:35 +02:00
gameState.levelTime > gameState.lastTickDown + 1000
) {
gameState.lastTickDown = gameState.levelTime;
decreaseCombo(
gameState,
gameState.perks.hot_start,
gameState.puckPosition,
gameState.gameZoneHeight - 2 * gameState.puckHeight,
);
}
}
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
if (
remainingBricks <= gameState.perks.skip_last &&
!gameState.autoCleanUses
) {
gameState.bricks.forEach((type, index) => {
if (type) {
explodeBrick(gameState, index, gameState.balls[0], true);
}
});
gameState.autoCleanUses++;
2025-04-11 20:34:11 +02:00
}
2025-04-20 21:14:35 +02:00
const hasPendingBricks = liveCount(gameState.respawns);
if (gameState.running && !remainingBricks && !hasPendingBricks) {
if (!gameState.winAt) {
gameState.winAt = gameState.levelTime + 5000;
}
} else {
2025-04-20 21:14:35 +02:00
gameState.winAt = 0;
}
2025-04-20 21:14:35 +02:00
if (
(gameState.running &&
// Delayed win when coins are still flying
gameState.winAt &&
gameState.levelTime > gameState.winAt) ||
// instant win condition
(gameState.levelTime && !remainingBricks && !liveCount(gameState.coins))
) {
if (gameState.startParams.computer_controlled) {
startComputerControlledGame(gameState.startParams.stress);
} else 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);
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.length) {
// Find closest ball
let closestBall = getClosestBall(gameState, coin.x, coin.y);
if (closestBall) {
let dist = distance2(closestBall, coin);
const minDist = gameState.brickWidth * gameState.brickWidth;
if (
dist > minDist &&
dist < minDist * 4 * 4 * gameState.perks.ball_attracts_coins
) {
// Slow down coins in effect radius
const ratio =
1 - 0.02 * (0.5 + gameState.perks.ball_attracts_coins);
coin.vx *= ratio;
coin.vy *= ratio;
coin.vy *= ratio;
// Carry them
const dx =
((closestBall.x - coin.x) / dist) *
50 *
gameState.perks.ball_attracts_coins;
const dy =
((closestBall.y - coin.y) / dist) *
50 *
gameState.perks.ball_attracts_coins;
coin.vx += dx;
coin.vy += dy;
if (
!isOptionOn("basic") &&
Math.random() * gameState.perks.ball_attracts_coins * frames > 0.9
) {
makeParticle(
gameState,
coin.x + dx * 5,
coin.y + dy * 5,
dx * 2,
dy * 2,
rainbowColor(),
true,
gameState.coinSize / 2,
100,
);
}
}
}
}
if (gameState.perks.bricks_attract_coins) {
goToNearestBrick(
gameState,
coin,
gameState.perks.bricks_attract_coins * frames,
2,
false,
);
}
2025-04-10 21:40:45 +02:00
const ratio =
2025-04-20 21:14:35 +02:00
1 -
((gameState.perks.viscosity * 0.03 +
0.002 +
(coin.y > gameState.gameZoneHeight ? 0.2 : 0)) *
frames) /
(1 + gameState.perks.etherealcoins);
if (!gameState.perks.etherealcoins) {
coin.vy *= ratio;
coin.vx *= ratio;
}
if (coin.y > gameState.gameZoneHeight && coin.floatingTime < gameState.perks.buoy * 30) {
coin.floatingTime += frames
coin.vy -= 1.5
}
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
const flip =
gameState.perks.helium > 0 &&
Math.abs(coin.x - gameState.puckPosition) * 2 >
gameState.puckWidth + coin.size;
let dvy =
frames * coin.weight * 0.8 * (flip ? -gameState.perks.helium : 1);
if (gameState.perks.etherealcoins) {
if (gameState.perks.helium) {
dvy *= 0.2 / gameState.perks.etherealcoins;
} else {
dvy *= 0;
}
}
coin.vy += dvy;
if (
gameState.perks.helium &&
!isOptionOn("basic") &&
Math.random() < 0.1 * frames
) {
makeParticle(
gameState,
coin.x,
coin.y,
0,
dvy * 10,
getCoinRenderColor(gameState, coin),
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.previousY < gameState.gameZoneHeight &&
coin.y > gameState.gameZoneHeight &&
coin.vy > 0 &&
speed > 20 &&
!coin.floatingTime
) {
schedulGameSound(
gameState,
"plouf",
coin.x,
(clamp(speed, 20, 100) / 100) * 0.2,
);
if (gameState.perks.compound_interest) {
resetCombo(gameState, coin.x, gameState.gameZoneHeight - 20);
}
if (!isOptionOn("basic")) {
makeParticle(
gameState,
coin.x,
gameState.gameZoneHeight,
-coin.vx / 5,
-coin.vy / 5,
getCoinRenderColor(gameState, coin),
false,
);
}
}
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);
2025-04-21 09:06:15 +02:00
} else if (
coin.y > gameState.canvasHeight + coinRadius * 10 ||
coin.y < -coinRadius * 10 ||
coin.x < -coinRadius * 10 ||
coin.x > gameState.canvasWidth + coinRadius * 10
) {
2025-04-20 21:14:35 +02:00
gameState.levelLostCoins += coin.points;
destroy(gameState.coins, coinIndex);
if (
gameState.combo < gameState.perks.fountain_toss * 30 &&
2025-04-21 09:06:15 +02:00
Math.random() / coin.points < (1 / gameState.combo) * gameState.perks.fountain_toss
2025-04-20 21:14:35 +02:00
) {
2025-04-21 09:06:15 +02:00
increaseCombo(gameState, 1,
clamp(coin.x,20, gameState.canvasWidth-20 ),
clamp(coin.y,20, gameState.gameZoneHeight-20 )
);
2025-04-20 21:14:35 +02:00
}
}
const hitBrick = coinBrickHitCheck(gameState, coin);
if (gameState.perks.metamorphosis && typeof hitBrick !== "undefined") {
if (
gameState.bricks[hitBrick] &&
coin.color !== gameState.bricks[hitBrick] &&
2025-04-21 09:06:15 +02:00
gameState.bricks[hitBrick] !== "black" &&
2025-04-20 21:14:35 +02:00
coin.metamorphosisPoints
) {
// Not using setbrick because we don't want to reset HP
gameState.bricks[hitBrick] = coin.color;
coin.metamorphosisPoints--;
schedulGameSound(gameState, "colorChange", coin.x, 0.3);
if (gameState.perks.hypnosis) {
const closestBall = getClosestBall(gameState, coin.x, coin.y);
if (closestBall) {
coin.x = closestBall.x;
coin.y = closestBall.y;
coin.vx = (Math.random() - 0.5) * gameState.baseSpeed;
coin.vy = (Math.random() - 0.5) * gameState.baseSpeed;
coin.metamorphosisPoints = gameState.perks.metamorphosis;
}
}
}
}
2025-04-10 21:40:45 +02:00
2025-04-11 20:34:11 +02:00
if (
2025-04-20 21:14:35 +02:00
(!gameState.perks.ghost_coins && typeof hitBrick !== "undefined") ||
hitBorder
2025-04-11 20:34:11 +02:00
) {
2025-04-20 21:14:35 +02:00
const ratio = 1 - 0.2 / (1 + gameState.perks.etherealcoins);
coin.vx *= ratio;
coin.vy *= ratio;
if (Math.abs(coin.vy) < 1) {
coin.vy = 0;
}
coin.sa *= 0.9;
if (speed > 20 && !coin.collidedLastFrame) {
schedulGameSound(gameState, "coinBounce", coin.x, 0.2);
}
coin.collidedLastFrame = true;
} else {
coin.collidedLastFrame = false;
}
});
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
) {
// switch speeds
let tempVx = a.vx;
let tempVy = a.vy;
a.vx = b.vx;
a.vy = b.vy;
b.vx = tempVx;
b.vy = tempVy;
// Compute center
let x = (a.x + b.x) / 2;
let y = (a.y + b.y) / 2;
// space out the balls with extra speed
if (gameState.perks.shocks > 1) {
const limit = gameState.baseSpeed * gameState.perks.shocks / 2;
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,
Math.max(0, gameState.perks.shocks - 1),
);
}
}),
);
}
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 * frames;
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
2025-04-21 09:06:15 +02:00
if (gameState.perks.top_is_lava == 1) {
2025-04-20 21:14:35 +02:00
makeParticle(
2025-04-11 20:34:11 +02:00
gameState,
2025-04-20 21:14:35 +02:00
gameState.offsetXRoundedDown +
Math.random() * gameState.gameZoneWidthRoundedUp,
0,
(Math.random() - 0.5) * 10,
5,
"#FF0000",
2025-04-11 20:34:11 +02:00
true,
gameState.coinSize / 2,
2025-04-20 21:14:35 +02:00
100 * (Math.random() + 1),
);
2025-04-10 14:49:28 +02:00
}
2025-04-11 20:34:51 +02:00
2025-04-21 09:06:15 +02:00
if (gameState.perks.left_is_lava == 1) {
2025-04-20 21:14:35 +02:00
makeParticle(
gameState,
gameState.offsetXRoundedDown,
Math.random() * gameState.gameZoneHeight,
5,
(Math.random() - 0.5) * 10,
"#FF0000",
true,
gameState.coinSize / 2,
100 * (Math.random() + 1),
);
}
2025-03-26 08:35:49 +01:00
2025-04-21 09:06:15 +02:00
if (gameState.perks.right_is_lava == 1) {
2025-04-20 21:14:35 +02:00
makeParticle(
gameState,
gameState.offsetXRoundedDown + gameState.gameZoneWidthRoundedUp,
Math.random() * gameState.gameZoneHeight,
-5,
(Math.random() - 0.5) * 10,
"#FF0000",
true,
gameState.coinSize / 2,
100 * (Math.random() + 1),
);
}
2025-03-26 08:35:49 +01:00
2025-04-20 18:40:41 +02:00
if (gameState.perks.compound_interest) {
2025-04-20 21:14:35 +02:00
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,
"#FF0000",
true,
gameState.coinSize / 2,
100 * (Math.random() + 1),
);
2025-04-20 18:40:41 +02:00
}
2025-04-20 21:14:35 +02:00
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,
"#FF0000",
true,
gameState.coinSize / 2,
100 * (Math.random() + 1),
);
}
2025-04-20 21:14:35 +02:00
}
2025-04-20 18:40:41 +02:00
2025-04-20 21:14:35 +02:00
// Respawn what's needed, show particles
forEachLiveOne(gameState.respawns, (r, ri) => {
if (gameState.bricks[r.index]) {
destroy(gameState.respawns, ri);
} else if (gameState.levelTime > r.time) {
setBrick(gameState, r.index, r.color);
destroy(gameState.respawns, ri);
} else {
const {index, color} = r;
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,
);
}
2025-04-20 21:14:35 +02:00
});
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
forEachLiveOne(gameState.particles, (p, pi) => {
if (gameState.levelTime > p.time + p.duration) {
destroy(gameState.particles, pi);
}
2025-04-20 21:14:35 +02:00
});
forEachLiveOne(gameState.texts, (p, pi) => {
if (gameState.levelTime > p.time + p.duration) {
destroy(gameState.texts, pi);
2025-04-11 20:34:11 +02:00
}
2025-04-20 21:14:35 +02:00
});
forEachLiveOne(gameState.lights, (p, pi) => {
if (gameState.levelTime > p.time + p.duration) {
destroy(gameState.lights, pi);
2025-04-11 20:34:11 +02:00
}
});
2025-04-20 21:14:35 +02:00
}
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
export function ballTick(gameState: GameState, ball: Ball, frames: 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 (telekinesisEffectRate(gameState, ball) > 0) {
speedLimitDampener += 3;
ball.vx +=
((gameState.puckPosition - ball.x) / 1000) *
frames *
gameState.perks.telekinesis *
telekinesisEffectRate(gameState, ball);
}
if (yoyoEffectRate(gameState, ball) > 0) {
speedLimitDampener += 3;
ball.vx +=
((gameState.puckPosition - ball.x) / 1000) *
frames *
gameState.perks.yoyo *
yoyoEffectRate(gameState, ball);
2025-04-11 20:34:51 +02:00
}
2025-04-20 21:14:35 +02:00
if (ball.hitSinceBounce < gameState.perks.bricks_attract_ball * 3) {
goToNearestBrick(
2025-04-11 20:34:51 +02:00
gameState,
2025-04-20 21:14:35 +02:00
ball,
gameState.perks.bricks_attract_ball * frames * 0.2,
2 + gameState.perks.bricks_attract_ball,
Math.random() < 0.5 * frames,
);
2025-04-11 20:34:51 +02:00
}
2025-04-09 11:28:32 +02:00
2025-04-20 21:14:35 +02:00
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;
}
2025-04-20 21:14:35 +02:00
// 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;
2025-03-29 15:00:44 +01:00
}
2025-04-20 21:14:35 +02:00
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);
}
2025-04-09 11:28:32 +02:00
}
2025-04-20 21:14:35 +02:00
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);
}
2025-04-09 11:28:32 +02:00
}
2025-04-20 21:14:35 +02:00
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,
);
2025-04-09 11:28:32 +02:00
}
2025-04-20 21:14:35 +02:00
const borderHitCode = bordersHitCheck(
2025-04-11 20:34:51 +02:00
gameState,
2025-04-20 21:14:35 +02:00
ball,
gameState.ballSize / 2,
frames,
);
if (borderHitCode) {
2025-04-21 09:06:15 +02:00
ball.sidesHitsSinceBounce++
if (ball.sidesHitsSinceBounce <= gameState.perks.three_cushion * 3) {
increaseCombo(gameState, 1, ball.x, ball.y);
}
2025-04-20 21:14:35 +02:00
if (
gameState.perks.left_is_lava &&
borderHitCode % 2 &&
ball.x < gameState.offsetX + gameState.gameZoneWidth / 2
) {
resetCombo(gameState, ball.x, ball.y);
}
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
if (
gameState.perks.right_is_lava &&
borderHitCode % 2 &&
ball.x > gameState.offsetX + gameState.gameZoneWidth / 2
) {
resetCombo(gameState, ball.x, ball.y);
}
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
if (gameState.perks.top_is_lava && borderHitCode >= 2) {
resetCombo(gameState, ball.x, ball.y + gameState.ballSize * 3);
}
if (gameState.perks.trampoline) {
decreaseCombo(
gameState,
gameState.perks.trampoline,
ball.x,
ball.y + gameState.ballSize,
);
}
2025-04-11 20:34:51 +02:00
2025-04-20 21:14:35 +02:00
schedulGameSound(gameState, "wallBeep", ball.x, 1);
gameState.levelWallBounces++;
gameState.runStatistics.wall_bounces++;
2025-03-28 19:40:59 +01:00
}
2025-04-20 21:14:35 +02:00
// 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;
2025-04-09 11:28:32 +02:00
if (
2025-04-20 21:14:35 +02:00
ball.y > ylimit &&
ball.vy > 0 &&
(ballIsUnderPuck ||
(gameState.balls.length < 2 &&
gameState.perks.extra_life &&
ball.y > ylimit + gameState.puckHeight / 2))
2025-04-09 11:28:32 +02:00
) {
2025-04-20 21:14:35 +02:00
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
? -1 / (1 + gameState.perks.concave_puck)
: 1),
);
ball.vx = speed * Math.cos(angle);
ball.vy = speed * Math.sin(angle);
schedulGameSound(gameState, "wallBeep", ball.x, 1);
} else {
ball.vy *= -1;
justLostALife(gameState, ball, ball.x, ball.y);
}
if (gameState.perks.streak_shots) {
resetCombo(gameState, ball.x, ball.y);
}
if (gameState.perks.trampoline) {
increaseCombo(gameState, gameState.perks.trampoline, ball.x, ball.y);
}
if (
gameState.perks.nbricks &&
ball.hitSinceBounce < gameState.perks.nbricks
) {
resetCombo(gameState, ball.x, ball.y);
}
2025-04-20 21:14:35 +02:00
if (!ball.hitSinceBounce && gameState.bricks.find((i) => i)) {
gameState.runStatistics.misses++;
if (gameState.perks.forgiving) {
const loss = Math.floor(
(gameState.levelMisses / 10 / gameState.perks.forgiving) *
(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,
"#FF0000",
t("play.missed_ball"),
gameState.puckHeight,
500,
);
}
gameState.runStatistics.puck_bounces++;
ball.hitSinceBounce = 0;
ball.brokenSinceBounce = 0;
2025-04-21 09:06:15 +02:00
ball.sidesHitsSinceBounce = 0;
2025-04-20 21:14:35 +02:00
ball.sapperUses = 0;
ball.piercePoints = gameState.perks.pierce * 3;
2025-04-09 11:28:32 +02:00
}
if (
2025-04-20 21:14:35 +02:00
gameState.running &&
2025-04-21 09:06:15 +02:00
(
ball.y > gameState.gameZoneHeight + gameState.ballSize / 2 ||
ball.y < -gameState.gameZoneHeight ||
ball.x < -gameState.gameZoneHeight ||
ball.x > gameState.canvasWidth + gameState.gameZoneHeight
)
) {
2025-04-20 21:14:35 +02:00
ball.destroyed = true;
gameState.runStatistics.balls_lost++;
if (!gameState.balls.find((b) => !b.destroyed)) {
if (gameState.startParams.computer_controlled) {
startComputerControlledGame(gameState.startParams.stress);
} else {
gameOver(
t("gameOver.lost.title"),
t("gameOver.lost.summary", {score: gameState.score}),
);
}
}
2025-04-02 19:50:05 +02:00
}
2025-04-20 21:14:35 +02:00
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") {
const initialBrickColor = gameState.bricks[hitBrick];
ball.hitSinceBounce++;
2025-04-21 09:06:15 +02:00
if (!ball.sidesHitsSinceBounce && gameState.perks.three_cushion) {
resetCombo(gameState, ball.x, ball.y);
}
2025-04-20 21:14:35 +02:00
if (gameState.perks.nbricks) {
if (ball.hitSinceBounce > gameState.perks.nbricks) {
resetCombo(gameState, ball.x, ball.y);
} else {
increaseCombo(gameState, gameState.perks.nbricks, ball.x, ball.y);
}
// We need to reset at each hit, otherwise it's just an OP version of single puck hit streak
}
2025-04-02 19:50:05 +02:00
2025-04-20 21:14:35 +02:00
let pierce = false;
let damage =
1 +
(shouldPierceByColor(gameState, vhit, hhit, chit)
? gameState.perks.pierce_color
: 0);
2025-03-29 21:28:05 +01:00
2025-04-20 21:14:35 +02:00
gameState.brickHP[hitBrick] -= damage;
2025-03-29 21:28:05 +01:00
2025-04-20 21:14:35 +02:00
const used = Math.min(
ball.piercePoints,
Math.max(1, gameState.brickHP[hitBrick] + 1),
);
gameState.brickHP[hitBrick] -= used;
ball.piercePoints -= used;
2025-03-29 15:00:44 +01:00
2025-04-20 21:14:35 +02:00
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;
}
}
2025-04-02 19:50:05 +02:00
2025-04-20 21:14:35 +02:00
if (!gameState.brickHP[hitBrick]) {
ball.brokenSinceBounce++;
2025-04-21 09:06:15 +02:00
applyOttawaTreatyPerk(gameState, hitBrick, ball)
2025-04-20 21:14:35 +02:00
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++;
}
2025-04-21 09:06:15 +02:00
2025-04-20 21:14:35 +02:00
} else {
schedulGameSound(gameState, "wallBeep", x, 1);
makeLight(
gameState,
brickCenterX(gameState, hitBrick),
brickCenterY(gameState, hitBrick),
"#FFFFFF",
gameState.brickWidth + 2,
50 * gameState.brickHP[hitBrick],
);
}
2025-04-09 11:28:32 +02:00
}
2025-04-10 21:40:45 +02:00
2025-04-09 11:28:32 +02:00
if (
2025-04-20 21:14:35 +02:00
!isOptionOn("basic") &&
ballTransparency(ball, gameState) < Math.random()
2025-04-09 11:28:32 +02:00
) {
2025-04-20 21:14:35 +02:00
const remainingPierce = ball.piercePoints;
const remainingSapper = ball.sapperUses < gameState.perks.sapper;
const willMiss =
isOptionOn("red_miss") && ball.vy > 0 && !ball.hitSinceBounce;
const extraCombo = gameState.combo - 1;
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
if (
willMiss ||
(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 ? "#ffb92a" : "#FF0000")) ||
(willMiss && "#FF0000") ||
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,
);
}
2025-03-16 17:45:29 +01:00
}
2025-03-18 14:16:12 +01:00
}
2025-03-28 19:40:59 +01:00
function justLostALife(gameState: GameState, ball: Ball, x: number, y: number) {
2025-04-20 21:14:35 +02:00
gameState.perks.extra_life -= 1;
if (gameState.perks.extra_life < 0) {
gameState.perks.extra_life = 0;
} else if (gameState.perks.sacrifice) {
gameState.combo *= gameState.perks.sacrifice;
gameState.bricks.forEach(
(color, index) => color && explodeBrick(gameState, index, ball, true),
);
}
2025-03-29 21:28:05 +01:00
2025-04-20 21:14:35 +02:00
schedulGameSound(gameState, "lifeLost", ball.x, 1);
2025-04-20 21:14:35 +02:00
if (!isOptionOn("basic")) {
for (let i = 0; i < 10; i++)
makeParticle(
gameState,
x,
y,
Math.random() * gameState.baseSpeed * 3,
gameState.baseSpeed * 3,
"#FF0000",
false,
gameState.coinSize / 2,
150,
);
}
2025-03-28 19:40:59 +01:00
}
2025-03-28 11:58:58 +01:00
2025-03-18 14:16:12 +01:00
function makeCoin(
2025-04-20 21:14:35 +02:00
gameState: GameState,
x: number,
y: number,
vx: number,
vy: number,
color = "#ffd300",
points = 1,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
let weight = 0.8 + Math.random() * 0.2 + Math.min(2, points * 0.01);
weight *= 5 / (5 + gameState.perks.etherealcoins);
if (gameState.perks.trickledown) y = -20;
if (
gameState.perks.rainbow &&
Math.random() > 1 / (1 + gameState.perks.rainbow)
)
color = rainbowColor();
append(gameState.coins, (p: Partial<Coin>) => {
p.x = x;
p.y = y;
p.collidedLastFrame = true;
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.points = points;
p.weight = weight;
p.metamorphosisPoints = gameState.perks.metamorphosis;
p.floatingTime = 0
});
2025-03-18 14:16:12 +01:00
}
2025-03-18 14:16:12 +01:00
function makeParticle(
2025-04-20 21:14:35 +02:00
gameState: GameState,
x: number,
y: number,
vx: number,
vy: number,
color: colorString,
ethereal = false,
size = 8,
duration = 150,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
append(gameState.particles, (p: Partial<ParticleFlash>) => {
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;
});
2025-03-18 14:16:12 +01:00
}
2025-03-18 14:16:12 +01:00
function makeText(
2025-04-20 21:14:35 +02:00
gameState: GameState,
x: number,
y: number,
color: colorString,
text: string,
size = 20,
duration = 500,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
append(gameState.texts, (p: Partial<TextFlash>) => {
p.time = gameState.levelTime;
p.x = x;
p.y = y;
p.color = color;
p.size = size;
p.duration = clamp(duration, 400, 2000);
p.text = text;
});
2025-03-18 14:16:12 +01:00
}
2025-03-16 17:45:29 +01:00
2025-03-18 14:16:12 +01:00
function makeLight(
2025-04-20 21:14:35 +02:00
gameState: GameState,
x: number,
y: number,
color: colorString,
size = 8,
duration = 150,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
append(gameState.lights, (p: Partial<LightFlash>) => {
p.time = gameState.levelTime;
p.x = x;
p.y = y;
p.color = color;
p.size = size;
p.duration = duration;
});
2025-03-18 14:16:12 +01:00
}
2025-03-18 14:16:12 +01:00
export function append<T>(
2025-04-20 21:14:35 +02:00
where: ReusableArray<T>,
makeItem: (match: Partial<T>) => void,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
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++;
2025-03-16 17:45:29 +01:00
}
2025-03-18 08:19:20 +01:00
2025-03-18 14:16:12 +01:00
export function destroy<T>(where: ReusableArray<T>, index: number) {
2025-04-20 21:14:35 +02:00
if (where.list[index].destroyed) return;
where.list[index].destroyed = true;
where.indexMin = Math.min(where.indexMin, index);
where.total--;
2025-03-18 14:16:12 +01:00
}
2025-03-18 08:19:20 +01:00
2025-03-18 14:16:12 +01:00
export function liveCount<T>(where: ReusableArray<T>) {
2025-04-20 21:14:35 +02:00
return where.total;
2025-03-18 14:16:12 +01:00
}
export function empty<T>(where: ReusableArray<T>) {
2025-04-20 21:14:35 +02:00
let destroyed = 0;
where.total = 0;
where.indexMin = 0;
where.list.forEach((i) => {
if (!i.destroyed) {
i.destroyed = true;
destroyed++;
}
});
return destroyed;
2025-03-18 08:19:20 +01:00
}
2025-03-18 14:16:12 +01:00
export function forEachLiveOne<T>(
2025-04-20 21:14:35 +02:00
where: ReusableArray<T>,
cb: (t: T, index: number) => void,
2025-03-18 14:16:12 +01:00
) {
2025-04-20 21:14:35 +02:00
where.list.forEach((item: T, index: number) => {
if (item && !item.destroyed) {
cb(item, index);
}
});
2025-04-11 20:34:51 +02:00
}
2025-04-11 20:34:11 +02:00
2025-04-11 20:34:51 +02:00
function goToNearestBrick(
2025-04-20 21:14:35 +02:00
gameState: GameState,
coin: Ball | Coin,
strength,
size = 2,
particle = false,
2025-04-11 20:34:51 +02:00
) {
2025-04-20 21:14:35 +02:00
const row = Math.floor(coin.y / gameState.brickWidth);
const col = Math.floor((coin.x - gameState.offsetX) / gameState.brickWidth);
let vx = 0,
vy = 0;
for (let dcol = -size; dcol < size; dcol++) {
for (let drow = -size; drow < size; drow++) {
const index = getRowColIndex(gameState, row + drow, col + dcol);
if (gameState.bricks[index]) {
const dx =
brickCenterX(gameState, index) +
(clamp(-dcol, -1, 1) * gameState.brickWidth) / 2 -
coin.x;
const dy =
brickCenterY(gameState, index) +
(clamp(-drow, -1, 1) * gameState.brickWidth) / 2 -
coin.y;
const d2 = dx * dx + dy * dy;
vx += (dx / d2) * 20;
vy += (dy / d2) * 20;
}
}
2025-04-09 11:28:32 +02:00
}
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
coin.vx += vx * strength;
coin.vy += vy * strength;
const s2 = coin.vx * coin.vx + coin.vy * coin.vy;
if (s2 > gameState.baseSpeed * gameState.baseSpeed * 2) {
coin.vx *= 0.95;
coin.vy *= 0.95;
}
2025-04-11 20:34:11 +02:00
2025-04-20 21:14:35 +02:00
if ((vx || vy) && particle) {
makeParticle(
gameState,
coin.x,
coin.y,
-vx * 2,
-vy * 2,
rainbowColor(),
true,
);
}
2025-03-18 14:16:12 +01:00
}
2025-04-20 21:14:35 +02:00
2025-04-21 09:06:15 +02:00
function applyOttawaTreatyPerk(gameState: GameState, index: number, ball: Ball) {
if (!gameState.perks.ottawa_treaty) return
if (ball.sapperUses) return
2025-04-20 21:14:35 +02:00
const originalColor = gameState.bricks[index]
2025-04-21 09:06:15 +02:00
if (originalColor == 'black') return
2025-04-20 21:14:35 +02:00
const x = index % gameState.gridSize
const y = Math.floor(index / gameState.gridSize)
let converted = 0
for (let dx = -1; dx <= 1; dx++)
for (let dy = -1; dy <= 1; dy++)
if (dx || dy) {
const nIndex = getRowColIndex(gameState, y + dy, x + dx)
if (gameState.bricks[nIndex] && gameState.bricks[nIndex] === 'black') {
2025-04-21 09:06:15 +02:00
2025-04-20 21:14:35 +02:00
setBrick(gameState, nIndex, originalColor)
schedulGameSound(gameState, "colorChange", brickCenterX(gameState, index), 1)
// Avoid infinite bricks generation hack
2025-04-21 09:06:15 +02:00
ball.sapperUses = Infinity
2025-04-20 21:14:35 +02:00
converted++
// Don't convert more than one brick per hit normally
2025-04-21 09:06:15 +02:00
if (converted >= gameState.perks.ottawa_treaty) return
2025-04-20 21:14:35 +02:00
}
}
2025-04-21 09:06:15 +02:00
return
2025-04-20 21:14:35 +02:00
}