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

58
src/combo.ts Normal file
View file

@ -0,0 +1,58 @@
import {GameState} from "./types";
import {sounds} from "./sounds";
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 (!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(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) {
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,
});
}
}
}

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,

42
src/game_utils.test.ts Normal file
View file

@ -0,0 +1,42 @@
import {getMajorityValue, sample, sumOfKeys} from "./game_utils";
describe('getMajorityValue', ()=>{
it('returns the most common string',()=>{
expect(getMajorityValue(['1','1','2','2','3','2','3','2','2','1'])).toStrictEqual('2')
})
it('returns the only string',()=>{
expect(getMajorityValue(['1'])).toStrictEqual('1')
})
it('returns nothing for empty array',()=>{
expect(getMajorityValue([])).toStrictEqual(undefined)
})
})
describe('sample', ()=>{
it('returns a random pick from the array',()=>{
expect(['1','2','3'].includes(sample(['1','2','3']))).toBeTruthy()
})
it('returns the only item if there is just one',()=>{
expect(sample(['1'])).toStrictEqual('1')
})
it('returns nothing for empty array',()=>{
expect(sample([])).toStrictEqual(undefined)
})
})
describe('sumOfKeys', ()=>{
it('returns the sum of the keys of an array',()=>{
expect(sumOfKeys({a:1,b:2})).toEqual(3)
})
it('returns 0 for an empty object',()=>{
expect(sumOfKeys({})).toEqual(0)
})
it('returns 0 for undefined',()=>{
expect(sumOfKeys(undefined)).toEqual(0)
})
it('returns 0 for null',()=>{
expect(sumOfKeys(null)).toEqual(0)
})
})

View file

@ -1,4 +1,5 @@
import {PerksMap, Upgrade} from "./types";
export function getMajorityValue(arr: string[]): string {
const count: { [k: string]: number } = {};
@ -16,4 +17,10 @@ export function sample<T>(arr: T[]): T {
export function sumOfKeys(obj:{[key:string]:number} | undefined | null){
if(!obj) return 0
return Object.values(obj)?.reduce((a,b)=>a+b,0) ||0
}
}
export const makeEmptyPerksMap = (upgrades:Upgrade[]) => {
const p = {} as any;
upgrades.forEach((u) => (p[u.id] = 0));
return p as PerksMap;
};

View file

@ -41,7 +41,7 @@
{
"name": "Dots",
"size": 9,
"bricks": "b_t_a_c_C__________b_t_a_c__________v_b_t_a_c__________v_b_t_a__________p_v_b_t_a",
"bricks": "b_t_a_c____________b_t_a_c__________P_b_t_a_c__________P_b_t_a____________P_b_t_a",
"svg": null
},
{
@ -95,7 +95,7 @@
{
"name": "Temple",
"size": 11,
"bricks": "_______________WWW______WWWWWWW___WWWWWWWWW___t_t_t_t____b_b_b_b____v_v_v_v____p_p_p_p____P_P_P_P____WWWWWWW___WWWWWWWWW_",
"bricks": "_______________WWW______WWWWWWW___WWWWWWWWW___b_b_b_b____b_b_b_b____v_v_v_v____P_P_P_P____P_P_P_P____WWWWWWW___WWWWWWWWW_",
"svg": null,
"color": ""
},
@ -109,7 +109,7 @@
{
"name": "Ship",
"size": 11,
"bricks": "____sWW________sWWW_______sWWW_______s___OOOOOOOOOOOOOO_OBOBOBOBOO__OOOOOOOO_bbbbbbbbgbbbbgbbbbggbbbggbbbbbbbb",
"bricks": "____sWW________sWWW_______sWWW_______s___OOOOOOOOOOOOOO_OBOBOBOBOO__OOOOOOOO_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb___________",
"svg": 19
},
{
@ -279,7 +279,7 @@
{
"name": "Ocean Sunrise",
"size": 8,
"bricks": "SSSSSSSSSSSyySSSSSyyyySSSyyWWyySbttaattbbbttttbbbbbttbbbbbbbbbbb",
"bricks": "SSSSSSSSSSSyySSSSSyyyySSSyyyyyySbttttttbbbttttbbbbbttbbbbbbbbbbb",
"svg": 12,
"color": ""
},
@ -648,7 +648,7 @@
{
"name": "Bird",
"size": 13,
"bricks": "_______RRR____R____RSSSR___RR__RSSWWWR__RSR_RSWWBWR__RSSRRSW_WWyy_RSSSRSWWWR___RSSSSSSRR_____RRSSyyyy______RSyyyyy___RRRRSyyyy____RSSSRyyy_____RRRR________",
"bricks": "_______RRR____R____RSSSR___RR__RSSWWWR__RSR_RSWWBWR__RSSRRSWWWWyy_RSSSRSWWWR___RSSSSSSRR_____RRSSyyyy______RSyyyyy___RRRRSyyyy____RSSSRyyy_____RRRR______________________",
"svg": null,
"color": ""
},

View file

@ -45,7 +45,10 @@ function App() {
return <div onMouseUp={() => setApplying('')} onMouseLeave={() => setApplying('')}>
return <div onMouseUp={() => {
console.log('mouse up')
setApplying('')
}} >
<div id={"levels"}>
{
levels.map((level, li) => {
@ -58,9 +61,12 @@ function App() {
brickButtons.push(<button
key={index}
onMouseDown={() => {
const color = selected === bricks[index] ? '_' : applying
setApplying(color)
updateLevel(li, setBrick(level, index, color))
if(!applying){
console.log(selected, bricks[index],applying)
const color = selected === bricks[index] ? '_' : selected
setApplying(color)
updateLevel(li, setBrick(level, index, color))
}
}}
onMouseEnter={() => {
if (applying) {

View file

@ -33,6 +33,7 @@ export function moveLevel(level: RawLevel, dx: number, dy: number) {
}
export function setBrick(level: RawLevel, index: number, colorCode: string) {
console.log('setBrick', level.name, index, colorCode)
const {size} = level
const newBricks=[]
for (let x = 0; x < size; x++) {

28
src/loadGameData.test.ts Normal file
View file

@ -0,0 +1,28 @@
import _palette from "./palette.json";
import _rawLevelsList from "./levels.json";
import _appVersion from "./version.json";
describe('json data checks', ()=>{
it('_rawLevelsList has icon levels', ()=>{
expect(_rawLevelsList.filter(l=>l.name.startsWith('icon:')).length).toBeGreaterThan(10)
})
it('_rawLevelsList has non-icon few levels', ()=>{
expect(_rawLevelsList.filter(l=>!l.name.startsWith('icon:')).length).toBeGreaterThan(10)
})
it('_rawLevelsList has max 5 colors per level', ()=>{
const levelsWithManyBrickColors=_rawLevelsList.filter(l=>{
const uniqueBricks = l.bricks.split('').filter(b=>b!=='_' && b!=='black').filter((a,b,c)=>c.indexOf(a)===b)
return uniqueBricks.length>5
}).map(l=>l.name)
expect(levelsWithManyBrickColors).toEqual([])
})
it('Has a few colors', ()=>{
expect(Object.keys(_palette).length).toBeGreaterThan(10)
})
it('Has an _appVersion', ()=>{
expect(parseInt(_appVersion)).toBeGreaterThan(2000)
})
})

View file

@ -19,7 +19,6 @@ const levelIconHTMLCanvasCtx = levelIconHTMLCanvas.getContext("2d", {
function levelIconHTML(
bricks: string[],
levelSize: number,
levelName: string,
color: string,
) {
const size = 40;
@ -49,8 +48,8 @@ function levelIconHTML(
}
}
}
// I don't think many blind people will benefit for this, but it's nice to have something to put in "alt"
return `<img alt="${levelName}" width="${size}" height="${size}" src="${c.toDataURL()}"/>`;
return `<img alt="" width="${size}" height="${size}" src="${c.toDataURL()}"/>`;
}
export const icons = {} as { [k: string]: string };
@ -61,7 +60,7 @@ export const allLevels = rawLevelsList
.split("")
.map((c) => palette[c])
.slice(0, level.size * level.size);
const icon = levelIconHTML(bricks, level.size, level.name, level.color);
const icon = levelIconHTML(bricks, level.size, level.color);
icons[level.name] = icon;
return {
...level,

View file

@ -1,233 +1,237 @@
import {
gameState,
isSettingOn,
gameState,
isSettingOn,
} from "./game";
export const sounds = {
wallBeep: (pan: number) => {
if (!isSettingOn("sound")) return;
createSingleBounceSound(800, pixelsToPan(pan));
},
wallBeep: (pan: number) => {
if (!isSettingOn("sound")) return;
createSingleBounceSound(800, pixelsToPan(pan));
},
comboIncreaseMaybe: (combo: number, x: number, volume: number) => {
if (!isSettingOn("sound")) return;
let delta = 0;
if (!isNaN(lastComboPlayed)) {
if (lastComboPlayed < combo) delta = 1;
if (lastComboPlayed > combo) delta = -1;
comboIncreaseMaybe: (combo: number, x: number, volume: number) => {
if (!isSettingOn("sound")) return;
let delta = 0;
if (!isNaN(lastComboPlayed)) {
if (lastComboPlayed < combo) delta = 1;
if (lastComboPlayed > combo) delta = -1;
}
playShepard(delta, pixelsToPan(x), volume);
lastComboPlayed = combo;
},
comboDecrease() {
if (!isSettingOn("sound")) return;
playShepard(-1, 0.5, 0.5);
},
coinBounce: (pan: number, volume: number) => {
if (!isSettingOn("sound")) return;
createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle");
},
explode: (pan: number) => {
if (!isSettingOn("sound")) return;
createExplosionSound(pixelsToPan(pan));
},
lifeLost(pan: number) {
if (!isSettingOn("sound")) return;
createShatteredGlassSound(pixelsToPan(pan));
},
coinCatch(pan: number) {
if (!isSettingOn("sound")) return;
createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle");
},
colorChange(pan: number, volume: number) {
createSingleBounceSound(400, pixelsToPan(pan), volume , 0.5, "sine")
createSingleBounceSound(800, pixelsToPan(pan), volume * 0.5, 0.2, "square")
}
playShepard(delta, pixelsToPan(x), volume);
lastComboPlayed = combo;
},
comboDecrease() {
if (!isSettingOn("sound")) return;
playShepard(-1, 0.5, 0.5);
},
coinBounce: (pan: number, volume: number) => {
if (!isSettingOn("sound")) return;
createSingleBounceSound(1200, pixelsToPan(pan), volume, 0.1, "triangle");
},
explode: (pan: number) => {
if (!isSettingOn("sound")) return;
createExplosionSound(pixelsToPan(pan));
},
lifeLost(pan: number) {
if (!isSettingOn("sound")) return;
createShatteredGlassSound(pixelsToPan(pan));
},
coinCatch(pan: number) {
if (!isSettingOn("sound")) return;
createSingleBounceSound(900, pixelsToPan(pan), 0.8, 0.1, "triangle");
},
};
// How to play the code on the leftconst context = new window.AudioContext();
let audioContext: AudioContext,
audioRecordingTrack: MediaStreamAudioDestinationNode;
audioRecordingTrack: MediaStreamAudioDestinationNode;
export function getAudioContext() {
if (!audioContext) {
if (!isSettingOn("sound")) return null;
audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioRecordingTrack = audioContext.createMediaStreamDestination();
}
return audioContext;
if (!audioContext) {
if (!isSettingOn("sound")) return null;
audioContext = new (window.AudioContext || window.webkitAudioContext)();
audioRecordingTrack = audioContext.createMediaStreamDestination();
}
return audioContext;
}
export function getAudioRecordingTrack() {
getAudioContext();
return audioRecordingTrack;
getAudioContext();
return audioRecordingTrack;
}
function createSingleBounceSound(
baseFreq = 800,
pan = 0.5,
volume = 1,
duration = 0.1,
type: OscillatorType = "sine",
baseFreq = 800,
pan = 0.5,
volume = 1,
duration = 0.1,
type: OscillatorType = "sine",
) {
const context = getAudioContext();
if (!context) return;
const oscillator = createOscillator(context, baseFreq, type);
const context = getAudioContext();
if (!context) return;
const oscillator = createOscillator(context, baseFreq, type);
// Create a gain node to control the volume
const gainNode = context.createGain();
oscillator.connect(gainNode);
// Create a gain node to control the volume
const gainNode = context.createGain();
oscillator.connect(gainNode);
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime);
gainNode.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime);
gainNode.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
// Set up the gain envelope to simulate the impact and quick decay
gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact
gainNode.gain.exponentialRampToValueAtTime(
0.001,
context.currentTime + duration,
); // Quick decay
// Set up the gain envelope to simulate the impact and quick decay
gainNode.gain.setValueAtTime(0.8 * volume, context.currentTime); // Initial impact
gainNode.gain.exponentialRampToValueAtTime(
0.001,
context.currentTime + duration,
); // Quick decay
// Start the oscillator
oscillator.start(context.currentTime);
// Start the oscillator
oscillator.start(context.currentTime);
// Stop the oscillator after the decay
oscillator.stop(context.currentTime + duration);
// Stop the oscillator after the decay
oscillator.stop(context.currentTime + duration);
}
let noiseBuffer: AudioBuffer;
function getNoiseBuffer(context: AudioContext) {
if (!noiseBuffer) {
const bufferSize = context.sampleRate * 2; // 2 seconds
noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate);
const output = noiseBuffer.getChannelData(0);
if (!noiseBuffer) {
const bufferSize = context.sampleRate * 2; // 2 seconds
noiseBuffer = context.createBuffer(1, bufferSize, context.sampleRate);
const output = noiseBuffer.getChannelData(0);
// Fill the buffer with random noise
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
// Fill the buffer with random noise
for (let i = 0; i < bufferSize; i++) {
output[i] = Math.random() * 2 - 1;
}
}
}
return noiseBuffer;
return noiseBuffer;
}
function createExplosionSound(pan = 0.5) {
const context = getAudioContext();
if (!context) return;
// Create an audio buffer
const context = getAudioContext();
if (!context) return;
// Create an audio buffer
// Create a noise source
const noiseSource = context.createBufferSource();
noiseSource.buffer = getNoiseBuffer(context);
// Create a noise source
const noiseSource = context.createBufferSource();
noiseSource.buffer = getNoiseBuffer(context);
// Create a gain node to control the volume
const gainNode = context.createGain();
noiseSource.connect(gainNode);
// Create a gain node to control the volume
const gainNode = context.createGain();
noiseSource.connect(gainNode);
// Create a filter to shape the explosion sound
const filter = context.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency
gainNode.connect(filter);
// Create a filter to shape the explosion sound
const filter = context.createBiquadFilter();
filter.type = "lowpass";
filter.frequency.setValueAtTime(1000, context.currentTime); // Set the initial frequency
gainNode.connect(filter);
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime); // pan 0 to 1 maps to -1 to 1
// Connect filter to panner and then to the destination (speakers)
filter.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
// Connect filter to panner and then to the destination (speakers)
filter.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
// Ramp down the gain to simulate the explosion's fade-out
gainNode.gain.setValueAtTime(1, context.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1);
// Ramp down the gain to simulate the explosion's fade-out
gainNode.gain.setValueAtTime(1, context.currentTime);
gainNode.gain.exponentialRampToValueAtTime(0.01, context.currentTime + 1);
// Lower the filter frequency over time to create the "explosive" effect
filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1);
// Lower the filter frequency over time to create the "explosive" effect
filter.frequency.exponentialRampToValueAtTime(60, context.currentTime + 1);
// Start the noise source
noiseSource.start(context.currentTime);
// Start the noise source
noiseSource.start(context.currentTime);
// Stop the noise source after the sound has played
noiseSource.stop(context.currentTime + 1);
// Stop the noise source after the sound has played
noiseSource.stop(context.currentTime + 1);
}
function pixelsToPan(pan: number) {
return Math.max(
0,
Math.min(1, (pan - gameState.offsetXRoundedDown) / gameState.gameZoneWidthRoundedUp),
);
return Math.max(
0,
Math.min(1, (pan - gameState.offsetXRoundedDown) / gameState.gameZoneWidthRoundedUp),
);
}
let lastComboPlayed = NaN,
shepard = 6;
shepard = 6;
function playShepard(delta: number, pan: number, volume: number) {
const shepardMax = 11,
factor = 1.05945594920268,
baseNote = 392;
shepard += delta;
if (shepard > shepardMax) shepard = 0;
if (shepard < 0) shepard = shepardMax;
const shepardMax = 11,
factor = 1.05945594920268,
baseNote = 392;
shepard += delta;
if (shepard > shepardMax) shepard = 0;
if (shepard < 0) shepard = shepardMax;
const play = (note: number) => {
const freq = baseNote * Math.pow(factor, note);
const diff = Math.abs(note - shepardMax * 0.5);
const maxDistanceToIdeal = 1.5 * shepardMax;
const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal));
createSingleBounceSound(freq, pan, vol);
return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff;
};
const play = (note: number) => {
const freq = baseNote * Math.pow(factor, note);
const diff = Math.abs(note - shepardMax * 0.5);
const maxDistanceToIdeal = 1.5 * shepardMax;
const vol = Math.max(0, volume * (1 - diff / maxDistanceToIdeal));
createSingleBounceSound(freq, pan, vol);
return freq.toFixed(2) + " at " + Math.floor(vol * 100) + "% diff " + diff;
};
play(1 + shepardMax + shepard);
play(shepard);
play(-1 - shepardMax + shepard);
play(1 + shepardMax + shepard);
play(shepard);
play(-1 - shepardMax + shepard);
}
function createShatteredGlassSound(pan: number) {
const context = getAudioContext();
if (!context) return;
const oscillators = [
createOscillator(context, 3000, "square"),
createOscillator(context, 4500, "square"),
createOscillator(context, 6000, "square"),
];
const gainNode = context.createGain();
const noiseSource = context.createBufferSource();
noiseSource.buffer = getNoiseBuffer(context);
const context = getAudioContext();
if (!context) return;
const oscillators = [
createOscillator(context, 3000, "square"),
createOscillator(context, 4500, "square"),
createOscillator(context, 6000, "square"),
];
const gainNode = context.createGain();
const noiseSource = context.createBufferSource();
noiseSource.buffer = getNoiseBuffer(context);
oscillators.forEach((oscillator) => oscillator.connect(gainNode));
noiseSource.connect(gainNode);
gainNode.gain.setValueAtTime(0.2, context.currentTime);
oscillators.forEach((oscillator) => oscillator.start());
noiseSource.start();
oscillators.forEach((oscillator) =>
oscillator.stop(context.currentTime + 0.2),
);
noiseSource.stop(context.currentTime + 0.2);
gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2);
oscillators.forEach((oscillator) => oscillator.connect(gainNode));
noiseSource.connect(gainNode);
gainNode.gain.setValueAtTime(0.2, context.currentTime);
oscillators.forEach((oscillator) => oscillator.start());
noiseSource.start();
oscillators.forEach((oscillator) =>
oscillator.stop(context.currentTime + 0.2),
);
noiseSource.stop(context.currentTime + 0.2);
gainNode.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.2);
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime);
gainNode.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
// Create a stereo panner node for left-right panning
const panner = context.createStereoPanner();
panner.pan.setValueAtTime(pan * 2 - 1, context.currentTime);
gainNode.connect(panner);
panner.connect(context.destination);
panner.connect(audioRecordingTrack);
gainNode.connect(panner);
gainNode.connect(panner);
}
// Helper function to create an oscillator with a specific frequency
function createOscillator(
context: AudioContext,
frequency: number,
type: OscillatorType,
context: AudioContext,
frequency: number,
type: OscillatorType,
) {
const oscillator = context.createOscillator();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, context.currentTime);
return oscillator;
const oscillator = context.createOscillator();
oscillator.type = type;
oscillator.frequency.setValueAtTime(frequency, context.currentTime);
return oscillator;
}

1
src/types.d.ts vendored
View file

@ -69,6 +69,7 @@ export type Coin = {
color: colorString;
x: number;
y: number;
size: number;
previousX: number;
previousY: number;
vx: number;