5 colors /level, sound when ball or brick change color

This commit is contained in:
Renan LE CARO 2025-03-14 15:49:04 +01:00
parent b6fe46c9bc
commit 2e3ab3011f
21 changed files with 1379 additions and 598 deletions

View file

@ -6,18 +6,19 @@ import {
colorString,
GameState,
PerkId,
PerksMap,
RunHistoryItem, RunParams,
RunHistoryItem,
RunParams,
RunStats,
Upgrade,
} from "./types";
import {OptionId, options} from "./options";
import {getAudioContext, getAudioRecordingTrack, sounds} from "./sounds";
import {putBallsAtPuck, resetBalls} from "./resetBalls";
import {sumOfKeys} from "./game_utils";
import {makeEmptyPerksMap, sumOfKeys} from "./game_utils";
import {baseCombo, decreaseCombo, resetCombo} from "./combo";
export const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
const gameCanvas = document.getElementById("game") as HTMLCanvasElement;
const ctx = gameCanvas.getContext("2d", {
alpha: false,
}) as CanvasRenderingContext2D;
@ -29,69 +30,6 @@ bombSVG.src =
<path d="m6.1528 26.516c-2.6992-3.4942-2.9332-8.281-.58305-11.981a10.454 10.454 0 017.3701-4.7582c1.962-.27726 4.1646.05953 5.8835.90027l.45013.22017.89782-.87417c.83748-.81464.91169-.87499 1.0992-.90271.40528-.058713.58876.03425 1.1971.6116l.55451.52679 1.0821-1.0821c1.1963-1.1963 1.383-1.3357 2.1039-1.5877.57898-.20223 1.5681-.19816 2.1691.00897 1.4613.50314 2.3673 1.7622 2.3567 3.2773-.0058.95654-.24464 1.5795-.90924 2.3746-.40936.48928-.55533.81057-.57898 1.2737-.02039.41018.1109.77714.42322 1.1792.30172.38816.3694.61323.2797.93044-.12803.45666-.56674.71598-1.0242.60507-.601-.14597-1.3031-1.3088-1.3969-2.3126-.09459-1.0161.19245-1.8682.92392-2.7432.42567-.50885.5643-.82851.5643-1.3031 0-.50151-.14026-.83177-.51211-1.2028-.50966-.50966-1.0968-.64829-1.781-.41996l-.37348.12477-2.1006 2.1006.52597.55696c.45421.48194.5325.58876.57898.78855.09622.41588.07502.45014-.88396 1.4548l-.87173.9125.26339.57979a10.193 10.193 0 01.9231 4.1001c.03996 2.046-.41996 3.8082-1.4442 5.537-.55044.928-1.0185 1.5013-1.8968 2.3241-.83503.78284-1.5526 1.2827-2.4904 1.7361-3.4266 1.657-7.4721 1.3422-10.549-.82035-.73473-.51782-1.7312-1.4621-2.2515-2.1357zm21.869-4.5584c-.0579-.19734-.05871-2.2662 0-2.4545.11906-.39142.57898-.63361 1.0038-.53005.23812.05708.54147.32455.6116.5382.06279.19163.06769 2.1805.0065 2.3811-.12558.40773-.61649.67602-1.0462.57164-.234-.05708-.51615-.30498-.57568-.50722m3.0417-2.6013c-.12313-.6222.37837-1.1049 1.0479-1.0079.18348.0261.25279.08399 1.0071.83911.75838.75838.81301.82362.84074 1.0112.10193.68499-.40365 1.1938-1.034 1.0405-.1949-.0473-.28786-.12558-1.0144-.85216-.7649-.76409-.80241-.81057-.84645-1.0316m.61323-3.0629a.85623.85623 0 01.59284-.99975c.28949-.09214 2.1814-.08318 2.3917.01141.38734.17369.6279.61078.53984.98181-.06035.25606-.35391.57327-.60181.64992-.25279.07747-2.2278.053-2.4097-.03017-.26013-.11906-.46318-.36125-.51374-.61323" fill="#fff" opacity="0.3"/>
</svg>`);
const makeEmptyPerksMap = () => {
const p = {} as any;
upgrades.forEach((u) => (p[u.id] = 0));
return p as PerksMap;
};
export function baseCombo() {
return 1 + gameState.perks.base_combo * 3 + gameState.perks.smaller_puck * 5;
}
export function resetCombo(x: number | undefined, y: number | undefined) {
const prev = gameState.combo;
gameState.combo = baseCombo();
if (!gameState.levelTime) {
gameState.combo += gameState.perks.hot_start * 15;
}
if (prev > gameState.combo && gameState.perks.soft_reset) {
gameState.combo += Math.floor((prev - gameState.combo) / (1 + gameState.perks.soft_reset));
}
const lost = Math.max(0, prev - gameState.combo);
if (lost) {
for (let i = 0; i < lost && i < 8; i++) {
setTimeout(() => sounds.comboDecrease(), i * 100);
}
if (typeof x !== "undefined" && typeof y !== "undefined") {
gameState.flashes.push({
type: "text",
text: "-" + lost,
time: gameState.levelTime,
color: "red",
x: x,
y: y,
duration: 150,
size: gameState.puckHeight,
});
}
}
return lost;
}
export function decreaseCombo(by: number, x: number, y: number) {
const prev = gameState.combo;
gameState.combo = Math.max(baseCombo(), gameState.combo - by);
const lost = Math.max(0, prev - gameState.combo);
if (lost) {
sounds.comboDecrease();
if (typeof x !== "undefined" && typeof y !== "undefined") {
gameState.flashes.push({
type: "text",
text: "-" + lost,
time: gameState.levelTime,
color: "red",
x: x,
y: y,
duration: 300,
size: gameState.puckHeight,
});
}
}
}
export function play() {
if (gameState.running) return;
@ -341,7 +279,7 @@ async function openUpgradesPicker() {
gameState.runStatistics.upgrades_picked++;
}
resetCombo(undefined, undefined);
resetCombo(gameState, undefined, undefined);
resetBalls(gameState);
}
@ -361,7 +299,7 @@ export function setLevel(l: number) {
gameState.levelMisses = 0;
gameState.runStatistics.levelsPlayed++;
resetCombo(undefined, undefined);
resetCombo(gameState, undefined, undefined);
recomputeTargetBaseSpeed();
resetBalls(gameState);
@ -536,7 +474,7 @@ export function shouldPierceByColor(
export function coinBrickHitCheck(coin: Coin) {
// Make ball/coin bonce, and return bricks that were hit
const radius = gameState.coinSize / 2;
const radius = coin.size / 2;
const {x, y, previousX, previousY} = coin;
const vhit = hitsSomething(previousX, y, radius);
@ -646,7 +584,7 @@ export function tick() {
if (gameState.levelTime > gameState.lastTickDown + 1000 && gameState.perks.hot_start) {
gameState.lastTickDown = gameState.levelTime;
decreaseCombo(gameState.perks.hot_start, gameState.puckPosition, gameState.gameZoneHeight - 2 * gameState.puckHeight);
decreaseCombo(gameState, gameState.perks.hot_start, gameState.puckPosition, gameState.gameZoneHeight - 2 * gameState.puckHeight);
}
if (remainingBricks <= gameState.perks.skip_last && !gameState.autoCleanUses) {
@ -662,8 +600,8 @@ export function tick() {
setLevel(gameState.currentLevel + 1);
} else {
gameOver(
"Run finished with " + gameState.score + " points",
"You cleared all levels for this run.",
"Run finished ",
`You cleared all levels for this run, catching ${gameState.score} coins in total.`,
);
}
} else if (gameState.running || gameState.levelTime) {
@ -698,7 +636,7 @@ export function tick() {
coin.vy += delta * coin.weight * 0.8;
const speed = Math.abs(coin.sx) + Math.abs(coin.sx);
const hitBorder = bordersHitCheck(coin, coinRadius, delta);
const hitBorder = bordersHitCheck(coin, coin.size / 2, delta);
if (
coin.y > gameState.gameZoneHeight - coinRadius - gameState.puckHeight &&
@ -712,7 +650,7 @@ export function tick() {
} else if (coin.y > gameState.canvasHeight + coinRadius) {
coin.destroyed = true;
if (gameState.perks.compound_interest) {
resetCombo(coin.x, coin.y);
resetCombo(gameState, coin.x, coin.y);
}
}
@ -727,6 +665,7 @@ export function tick() {
) {
gameState.bricks[hitBrick] = coin.color;
coin.coloredABrick = true;
sounds.colorChange(coin.x,0.3)
}
}
if (typeof hitBrick !== "undefined" || hitBorder) {
@ -783,10 +722,10 @@ export function tick() {
});
}
if (gameState.combo > baseCombo()) {
if (gameState.combo > baseCombo(gameState)) {
// The red should still be visible on a white bg
const baseParticle = !isSettingOn("basic") &&
(gameState.combo - baseCombo()) * Math.random() > 5 &&
(gameState.combo - baseCombo(gameState)) * Math.random() > 5 &&
gameState.running && {
type: "particle" as const,
duration: 100 * (Math.random() + 1),
@ -955,7 +894,7 @@ export function ballTick(ball: Ball, delta: number) {
borderHitCode % 2 &&
ball.x < gameState.offsetX + gameState.gameZoneWidth / 2
) {
resetCombo(ball.x, ball.y);
resetCombo(gameState, ball.x, ball.y);
}
if (
@ -963,11 +902,11 @@ export function ballTick(ball: Ball, delta: number) {
borderHitCode % 2 &&
ball.x > gameState.offsetX + gameState.gameZoneWidth / 2
) {
resetCombo(ball.x, ball.y);
resetCombo(gameState, ball.x, ball.y);
}
if (gameState.perks.top_is_lava && borderHitCode >= 2) {
resetCombo(ball.x, ball.y + gameState.ballSize);
resetCombo(gameState, ball.x, ball.y + gameState.ballSize);
}
sounds.wallBeep(ball.x);
ball.bouncesList?.push({x: ball.previousX, y: ball.previousY});
@ -1010,7 +949,7 @@ export function ballTick(ball: Ball, delta: number) {
}
}
if (gameState.perks.streak_shots) {
resetCombo(ball.x, ball.y);
resetCombo(gameState, ball.x, ball.y);
}
if (gameState.perks.respawn) {
@ -1025,7 +964,7 @@ export function ballTick(ball: Ball, delta: number) {
if (!ball.hitSinceBounce) {
gameState.runStatistics.misses++;
gameState.levelMisses++;
resetCombo(ball.x, ball.y);
resetCombo(gameState, ball.x, ball.y);
gameState.flashes.push({
type: "text",
text: "miss",
@ -1245,6 +1184,11 @@ export function gameOver(title: string, intro: string) {
});
}
let unlockedItems = list.filter((u) => u.threshold > startTs && u.threshold < endTs);
if (unlockedItems.length) {
unlocksInfo += `<p>You unlocked ${unlockedItems.length} item(s) : ${unlockedItems.map(u => u.title).join(', ')}</p>`
}
// Avoid the sad sound right as we restart a new games
gameState.combo = 1;
@ -1254,6 +1198,7 @@ export function gameOver(title: string, intro: string) {
text: `
${gameState.isCreativeModeRun ? "<p>This test run and its score are not being recorded</p>" : ""}
<p>${intro}</p>
<p>Your total cumulative score went from ${startTs} to ${endTs}.</p>
${unlocksInfo}
`,
actions: [
@ -1480,6 +1425,7 @@ export function explodeBrick(index: number, ball: Ball, isExplosion: boolean) {
gameState.coins.push({
points,
size: gameState.coinSize,//-Math.floor(Math.log2(points)),
color: gameState.perks.metamorphosis ? color : "gold",
x: cx,
y: cy,
@ -1515,10 +1461,16 @@ export function explodeBrick(index: number, ball: Ball, isExplosion: boolean) {
color
) {
if (gameState.perks.picky_eater) {
resetCombo(ball.x, ball.y);
resetCombo(gameState, ball.x, ball.y);
}
sounds.colorChange(ball.x,0.8)
gameState.lastExplosion=gameState.levelTime
gameState.ballsColor = color;
if(!isSettingOn('basic')) {
gameState.balls.forEach(ball=>{
spawnExplosion(7, ball.previousX, ball.previousY, color, 150, 15)
})
}
} else {
sounds.comboIncreaseMaybe(gameState.combo, ball.x, 1);
}
@ -1668,7 +1620,7 @@ export function render() {
drawCoin(
ctx,
coin.color,
gameState.coinSize,
coin.size,
coin.x,
coin.y,
level.color || "black",
@ -1738,7 +1690,7 @@ export function render() {
// The puck
ctx.globalAlpha = 1;
ctx.globalCompositeOperation = "source-over";
if (gameState.perks.streak_shots && gameState.combo > baseCombo()) {
if (gameState.perks.streak_shots && gameState.combo > baseCombo(gameState)) {
drawPuck(ctx, "red", gameState.puckWidth, gameState.puckHeight, -2);
}
drawPuck(ctx, gameState.puckColor, gameState.puckWidth, gameState.puckHeight);
@ -1781,7 +1733,7 @@ export function render() {
}
}
// Borders
const hasCombo = gameState.combo > baseCombo();
const hasCombo = gameState.combo > baseCombo(gameState);
ctx.globalCompositeOperation = "source-over";
if (gameState.offsetXRoundedDown) {
// draw outside of gaming area to avoid capturing borders in recordings
@ -1795,11 +1747,11 @@ export function render() {
if (hasCombo && gameState.perks.right_is_lava) ctx.fillRect(width - 1, 0, 1, height);
}
if (gameState.perks.top_is_lava && gameState.combo > baseCombo()) {
if (gameState.perks.top_is_lava && gameState.combo > baseCombo(gameState)) {
ctx.fillStyle = "red";
ctx.fillRect(gameState.offsetXRoundedDown, 0, gameState.gameZoneWidthRoundedUp, 1);
}
const redBottom = gameState.perks.compound_interest && gameState.combo > baseCombo();
const redBottom = gameState.perks.compound_interest && gameState.combo > baseCombo(gameState);
ctx.fillStyle = redBottom ? "red" : gameState.puckColor;
if (isSettingOn("mobile-mode")) {
ctx.fillRect(gameState.offsetXRoundedDown, gameState.gameZoneHeight, gameState.gameZoneWidthRoundedUp, 1);
@ -1837,7 +1789,7 @@ export function renderAllBricks() {
ctx.globalAlpha = 1;
const redBorderOnBricksWithWrongColor =
gameState.combo > baseCombo() && gameState.perks.picky_eater;
gameState.combo > baseCombo(gameState) && gameState.perks.picky_eater;
const newKey =
gameState.gameZoneWidth +
@ -2447,11 +2399,11 @@ async function openSettingsPanel() {
}
}
actions.push({
text: "Creative mode",
text: "Sandbox mode",
help:
getTotalScore() < creativeModeThreshold
? "Unlocks at total score $" + creativeModeThreshold
: "Test runs with custom perks",
: "Test any perk combination",
disabled: getTotalScore() < creativeModeThreshold,
async value() {
let creativeModePerks: Partial<{ [id in PerkId]: number }> = {},
@ -2549,7 +2501,7 @@ async function openUnlocksList() {
help:
ts >= threshold ? fullHelp : `Unlocks at total score ${threshold}.`,
disabled: ts < threshold,
value: {perk: id} as RunParams,
value: {perks: {[id]: 1}} as RunParams,
icon,
})),
...allLevels
@ -2944,7 +2896,7 @@ document.addEventListener("keydown", (e) => {
e.preventDefault();
});
document.addEventListener("keyup", (e) => {
document.addEventListener("keyup", async (e) => {
const focused = document.querySelector("button:focus");
if (e.key in pressed) {
setKeyPressed(e.key, 0);
@ -2966,6 +2918,8 @@ document.addEventListener("keyup", (e) => {
openSettingsPanel();
} else if (e.key.toLowerCase() === "s" && !alertsOpen) {
openScorePanel();
} else if (e.key.toLowerCase() === "r" && !alertsOpen) {
// TODO
} else {
return;
}
@ -2987,7 +2941,7 @@ function newGameState(params: RunParams): GameState {
restInRandomOrder.slice(0, 7 + 3).sort((a, b) => a.sortKey - b.sortKey),
);
const perks = {...makeEmptyPerksMap(), ...(params?.perks || {})}
const perks = {...makeEmptyPerksMap(upgrades), ...(params?.perks || {})}
const gameState: GameState = {
runLevels,