This commit is contained in:
Renan LE CARO 2025-03-14 11:59:49 +01:00
parent b0d8827e09
commit 4fb4c97734
15 changed files with 4190 additions and 3754 deletions

View file

@ -134,6 +134,11 @@ There's also an easy mode for kids (slower ball).
- [colin] wormhole - the puck sometimes don't bounce the ball back up but teleports it to the top of the screen as if it fell through from bottom to top. higher levels reduce the times it takes to reload that effect - [colin] wormhole - the puck sometimes don't bounce the ball back up but teleports it to the top of the screen as if it fell through from bottom to top. higher levels reduce the times it takes to reload that effect
- [colin] hitman - hit the marked brick for +5 combo. each level increases the combo you get for it. - [colin] hitman - hit the marked brick for +5 combo. each level increases the combo you get for it.
- [colin] sweet spot - place your puck directly below a moving spot at the top of the level to increase your combo - [colin] sweet spot - place your puck directly below a moving spot at the top of the level to increase your combo
- ball attracted by bricks of the color of the ball
- ball avoids brick of wrong color
- coins avoid ball of different color
- colored coins only (coins should be of the color of the ball to count)
# Balancing ideas # Balancing ideas

1256
dist/index.html vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

7
jest.config.js Normal file
View file

@ -0,0 +1,7 @@
/** @type {import('ts-jest').JestConfigWithTsJest} **/
module.exports = {
testEnvironment: "node",
transform: {
"^.+\.tsx?$": ["ts-jest",{}],
},
};

550
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -6,8 +6,8 @@
"start": "rm -rf .parcel-cache && run-p dev:*", "start": "rm -rf .parcel-cache && run-p dev:*",
"dev:game-fe": "parcel src/*.html --lazy --no-hmr", "dev:game-fe": "parcel src/*.html --lazy --no-hmr",
"dev:editor-be": "nodemon editserver.js --watch editserver.js", "dev:editor-be": "nodemon editserver.js --watch editserver.js",
"dev:watch-tests": "jest watch", "test": "jest --watch",
"build": "rm -f dist/* && parcel build src/index.html" "build": "npx jest && rm -f dist/* && parcel build src/index.html"
}, },
"browserslist": "since 2009", "browserslist": "since 2009",
"author": "Renan LE CARO", "author": "Renan LE CARO",
@ -18,7 +18,7 @@
"@types/react-dom": "^19.0.4", "@types/react-dom": "^19.0.4",
"body-parser": "^1.20.3", "body-parser": "^1.20.3",
"express": "^4.21.2", "express": "^4.21.2",
"http-server": "^14.1.1", "http-server": "^14.1.1",
"nodemon": "^3.1.9", "nodemon": "^3.1.9",
"npm-run-all": "^4.1.5", "npm-run-all": "^4.1.5",
"parcel": "^2.13.3", "parcel": "^2.13.3",
@ -26,5 +26,11 @@
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"svgo": "^3.3.2" "svgo": "^3.3.2"
},
"devDependencies": {
"@types/jest": "^29.5.14",
"jest": "^29.7.0",
"ts-jest": "^29.2.6",
"typescript": "^5.8.2"
} }
} }

File diff suppressed because it is too large Load diff

19
src/game_utils.ts Normal file
View file

@ -0,0 +1,19 @@
export function getMajorityValue(arr: string[]): string {
const count: { [k: string]: number } = {};
arr.forEach((v) => (count[v] = (count[v] || 0) + 1));
// Object.values inline polyfill
const max = Math.max(...Object.keys(count).map((k) => count[k]));
return sample(Object.keys(count).filter((k) => count[k] == max));
}
export function sample<T>(arr: T[]): T {
return arr[Math.floor(arr.length * Math.random())];
}
export function sumOfKeys(obj:{[key:string]:number} | undefined | null){
if(!obj) return 0
return Object.values(obj)?.reduce((a,b)=>a+b,0) ||0
}

View file

@ -1,12 +1,47 @@
import {resizeLevel} from "./levels_editor_util"; import {moveLevel, resizeLevel, setBrick} from "./levels_editor_util";
test('resizeLevel',()=>{ const baseLevel = {
name: '',
bricks: 'AAAA',
size: 2,
svg: null,
color: ''
}
describe('resizeLevel', () => {
it('should expand levels', () => {
expect(resizeLevel(baseLevel, 1)).toStrictEqual({bricks: 'AA_AA____', size: 3});
})
it('should shrink levels', () => {
expect(resizeLevel(baseLevel, -1)).toStrictEqual({bricks: 'A', size: 1});
})
})
expect(resizeLevel({ describe('moveLevel', () => {
name:'',
bricks:'AAAA', it('should do nothing when coords are 0/0', () => {
size:2, expect(moveLevel(baseLevel, 0, 0)).toStrictEqual({bricks: 'AAAA'});
svg:null, })
color:'' it('should move right', () => {
}, 1)).toBe({bricks:'AA_AA____',size:3}); expect(moveLevel(baseLevel, 1, 0)).toStrictEqual({bricks: '_A_A'});
}) })
it('should move left', () => {
expect(moveLevel(baseLevel, -1, 0)).toStrictEqual({bricks: 'A_A_'});
})
it('should move up', () => {
expect(moveLevel(baseLevel, 0, -1)).toStrictEqual({bricks: 'AA__'});
})
it('should move down', () => {
expect(moveLevel(baseLevel, 0, 1)).toStrictEqual({bricks: '__AA'});
})
})
describe('setBrick', () => {
it('should set the first brick', () => {
expect(setBrick(baseLevel, 0, 'C')).toStrictEqual({bricks: 'CAAA'});
})
it('should any brick', () => {
expect(setBrick(baseLevel, 2, 'C')).toStrictEqual({bricks: 'AACA'});
})
})

View file

@ -4,7 +4,7 @@ import _palette from './palette.json'
import _allLevels from './levels.json' import _allLevels from './levels.json'
import {getLevelBackground, hashCode} from "./getLevelBackground"; import {getLevelBackground, hashCode} from "./getLevelBackground";
import {createRoot} from 'react-dom/client'; import {createRoot} from 'react-dom/client';
import {useCallback, useState} from "react"; import {useCallback, useEffect, useState} from "react";
import {moveLevel, resizeLevel, setBrick} from "./levels_editor_util"; import {moveLevel, resizeLevel, setBrick} from "./levels_editor_util";
const backgrounds = _backgrounds as string[]; const backgrounds = _backgrounds as string[];
@ -14,17 +14,6 @@ const palette = _palette as Palette;
let allLevels = _allLevels as RawLevel[]; let allLevels = _allLevels as RawLevel[];
function save() {
return fetch('http://localhost:4400/src/levels.json', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: JSON.stringify(allLevels, null, 2)
})
}
function App() { function App() {
const [selected, setSelected] = useState('W') const [selected, setSelected] = useState('W')
@ -33,12 +22,29 @@ function App() {
const updateLevel = useCallback((index: number, change: Partial<RawLevel>) => { const updateLevel = useCallback((index: number, change: Partial<RawLevel>) => {
setLevels(list => list.map((l, li) => li === index ? {...l, ...change} : l)) setLevels(list => list.map((l, li) => li === index ? {...l, ...change} : l))
}, []); }, []);
const deleteLevel = useCallback((li: number) => { const deleteLevel = useCallback((li: number) => {
if (confirm('Delete level')) { if (confirm('Delete level')) {
allLevels = allLevels.filter((l, i) => i !== li) setLevels(allLevels.filter((l, i) => i !== li))
} }
}, []) }, [])
useEffect(()=>{
const timoutId= setTimeout(()=>{
return fetch('http://localhost:4400/src/levels.json', {
method: 'POST',
headers: {
'Content-Type': 'text/plain'
},
body: JSON.stringify(levels, null, 2)
});
},500)
return ()=>clearTimeout(timoutId)
},[levels])
return <div onMouseUp={() => setApplying('')} onMouseLeave={() => setApplying('')}> return <div onMouseUp={() => setApplying('')} onMouseLeave={() => setApplying('')}>
<div id={"levels"}> <div id={"levels"}>
{ {

View file

@ -3,10 +3,10 @@ import {RawLevel} from "./types";
export function resizeLevel(level: RawLevel, sizeDelta: number) { export function resizeLevel(level: RawLevel, sizeDelta: number) {
const {size, bricks} = level const {size, bricks} = level
const newSize = Math.max(1, size + sizeDelta) const newSize = Math.max(1, size + sizeDelta)
const newBricks = new Array(newSize * newSize).fill('_') const newBricks = []
for (let x = 0; x < Math.min(size, newSize); x++) { for (let x = 0; x < newSize; x++) {
for (let y = 0; y < Math.min(size, newSize); y++) { for (let y = 0; y < newSize; y++) {
newBricks[y * newSize + x] = bricks.split('')[y * size + x] || '_' newBricks[y * newSize + x] = brickAt(level, x, y )
} }
} }
return { return {
@ -15,12 +15,16 @@ export function resizeLevel(level: RawLevel, sizeDelta: number) {
} }
} }
export function brickAt(level:RawLevel, x:number,y:number){
return x>=0 && x < level.size && y>= 0 && y< level.size && level.bricks.split('')[y * level.size + x] || '_'
}
export function moveLevel(level: RawLevel, dx: number, dy: number) { export function moveLevel(level: RawLevel, dx: number, dy: number) {
const {size, bricks} = level const {size} = level
const newBricks = new Array(size * size).fill('_') const newBricks = new Array(size * size).fill('_')
for (let x = 0; x < size; x++) { for (let x = 0; x < size; x++) {
for (let y = 0; y < size; y++) { for (let y = 0; y < size; y++) {
newBricks[y * size + x] = bricks.split('')[(y - dy) * size + (x - dx)] || '_' newBricks[y * size + x] = brickAt(level, x - dx, y - dy)
} }
} }
return { return {
@ -29,12 +33,15 @@ export function moveLevel(level: RawLevel, dx: number, dy: number) {
} }
export function setBrick(level: RawLevel, index: number, colorCode: string) { export function setBrick(level: RawLevel, index: number, colorCode: string) {
let bricksString = level.bricks.slice(0, level.size * level.size) const {size} = level
const newBricks=[]
if (bricksString.length < level.size * level.size) { for (let x = 0; x < size; x++) {
bricksString += '_'.repeat(level.size * level.size - bricksString.length) for (let y = 0; y < size; y++) {
const brickIndex=y * size + x
newBricks[brickIndex] = (brickIndex === index && colorCode ) || brickAt(level, x , y)
}
}
return {
bricks: newBricks.join('')
} }
const bricks = bricksString.split('')
bricks[index] = colorCode
return {bricks: bricks.join('')}
} }

58
src/resetBalls.ts Normal file
View file

@ -0,0 +1,58 @@
import {GameState} from "./types";
import {getMajorityValue} from "./game_utils";
export function resetBalls(gameState: GameState) {
const count = 1 + (gameState.perks?.multiball || 0);
const perBall = gameState.puckWidth / (count + 1);
gameState.balls = [];
gameState.ballsColor = "#FFF";
if (gameState.perks.picky_eater || gameState.perks.pierce_color) {
gameState.ballsColor = getMajorityValue(gameState.bricks.filter((i) => i)) || "#FFF";
}
for (let i = 0; i < count; i++) {
const x = gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
const vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed;
gameState.balls.push({
x,
previousX: x,
y: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
previousY: gameState.gameZoneHeight - 1.5 * gameState.ballSize,
vx,
previousVX: vx,
vy: -gameState.baseSpeed,
previousVY: -gameState.baseSpeed,
sx: 0,
sy: 0,
sparks: 0,
piercedSinceBounce: 0,
hitSinceBounce: 0,
hitItem: [],
bouncesList: [],
sapperUses: 0,
});
}
}
export function putBallsAtPuck(gameState: GameState) {
// This reset could be abused to cheat quite easily
const count = gameState.balls.length;
const perBall = gameState.puckWidth / (count + 1);
gameState.balls.forEach((ball, i) => {
const x = gameState.puckPosition - gameState.puckWidth / 2 + perBall * (i + 1);
ball.x = x;
ball.previousX = x;
ball.y = gameState.gameZoneHeight - 1.5 * gameState.ballSize;
ball.previousY = ball.y;
ball.vx = Math.random() > 0.5 ? gameState.baseSpeed : -gameState.baseSpeed;
ball.previousVX = ball.vx;
ball.vy = -gameState.baseSpeed;
ball.previousVY = ball.vy;
ball.sx = 0;
ball.sy = 0;
ball.hitItem = [];
ball.hitSinceBounce = 0;
ball.piercedSinceBounce = 0;
});
}

View file

@ -1,8 +1,6 @@
import { import {
gameZoneWidthRoundedUp, gameState,
isSettingOn, isSettingOn,
offsetX,
offsetXRoundedDown,
} from "./game"; } from "./game";
export const sounds = { export const sounds = {
@ -160,7 +158,7 @@ function createExplosionSound(pan = 0.5) {
function pixelsToPan(pan: number) { function pixelsToPan(pan: number) {
return Math.max( return Math.max(
0, 0,
Math.min(1, (pan - offsetXRoundedDown) / gameZoneWidthRoundedUp), Math.min(1, (pan - gameState.offsetXRoundedDown) / gameState.gameZoneWidthRoundedUp),
); );
} }

284
src/types.d.ts vendored
View file

@ -1,153 +1,231 @@
import { rawUpgrades } from "./rawUpgrades"; import {rawUpgrades} from "./rawUpgrades";
export type colorString = string; export type colorString = string;
export type RawLevel = { export type RawLevel = {
name: string; name: string;
size: number; size: number;
bricks: string; bricks: string;
svg: number | null; svg: number | null;
color: string; color: string;
}; };
export type Level = { export type Level = {
name: string; name: string;
size: number; size: number;
bricks: colorString[]; bricks: colorString[];
svg: string; svg: string;
color: string; color: string;
threshold: number; threshold: number;
sortKey: number; sortKey: number;
}; };
export type Palette = { [k: string]: string }; export type Palette = { [k: string]: string };
export type Upgrade = { export type Upgrade = {
threshold: number; threshold: number;
giftable: boolean; giftable: boolean;
id: PerkId; id: PerkId;
name: string; name: string;
icon: string; icon: string;
max: number; max: number;
help: (lvl: number) => string; help: (lvl: number) => string;
fullHelp: string; fullHelp: string;
requires: PerkId | ""; requires: PerkId | "";
}; };
export type PerkId = (typeof rawUpgrades)[number]["id"]; export type PerkId = (typeof rawUpgrades)[number]["id"];
declare global { declare global {
interface Window { interface Window {
webkitAudioContext?: typeof AudioContext; webkitAudioContext?: typeof AudioContext;
} }
interface Document { interface Document {
webkitFullscreenEnabled?: boolean; webkitFullscreenEnabled?: boolean;
webkitCancelFullScreen?: () => void; webkitCancelFullScreen?: () => void;
} }
interface Element { interface Element {
webkitRequestFullscreen: typeof Element.requestFullscreen; webkitRequestFullscreen: typeof Element.requestFullscreen;
} }
interface MediaStream { interface MediaStream {
// https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html // https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html
// On firefox, the capture stream has the requestFrame option // On firefox, the capture stream has the requestFrame option
// instead of the track, go figure // instead of the track, go figure
requestFrame?: () => void; requestFrame?: () => void;
} }
} }
export type BallLike = { export type BallLike = {
x: number; x: number;
y: number; y: number;
vx?: number; vx?: number;
vy?: number; vy?: number;
}; };
export type Coin = { export type Coin = {
points: number; points: number;
color: colorString; color: colorString;
x: number; x: number;
y: number; y: number;
previousX: number; previousX: number;
previousY: number; previousY: number;
vx: number; vx: number;
vy: number; vy: number;
sx: number; sx: number;
sy: number; sy: number;
a: number; a: number;
sa: number; sa: number;
weight: number; weight: number;
destroyed?: boolean; destroyed?: boolean;
coloredABrick?: boolean; coloredABrick?: boolean;
}; };
export type Ball = { export type Ball = {
x: number; x: number;
previousX: number; previousX: number;
y: number; y: number;
previousY: number; previousY: number;
vx: number; vx: number;
vy: number; vy: number;
previousVX: number; previousVX: number;
previousVY: number; previousVY: number;
sx: number; sx: number;
sy: number; sy: number;
sparks: number; sparks: number;
piercedSinceBounce: number; piercedSinceBounce: number;
hitSinceBounce: number; hitSinceBounce: number;
hitItem: { index: number; color: string }[]; hitItem: { index: number; color: string }[];
bouncesList: { x: number; y: number }[]; bouncesList: { x: number; y: number }[];
sapperUses: number; sapperUses: number;
destroyed?: boolean; destroyed?: boolean;
}; };
interface BaseFlash { interface BaseFlash {
time: number; time: number;
color: colorString; color: colorString;
duration: number; duration: number;
size: number; size: number;
destroyed?: boolean; destroyed?: boolean;
x: number; x: number;
y: number; y: number;
} }
interface ParticleFlash extends BaseFlash { interface ParticleFlash extends BaseFlash {
type: "particle"; type: "particle";
vx: number; vx: number;
vy: number; vy: number;
ethereal: boolean; ethereal: boolean;
} }
interface TextFlash extends BaseFlash { interface TextFlash extends BaseFlash {
type: "text"; type: "text";
text: string; text: string;
} }
interface BallFlash extends BaseFlash { interface BallFlash extends BaseFlash {
type: "ball"; type: "ball";
} }
export type Flash = ParticleFlash | TextFlash | BallFlash; export type Flash = ParticleFlash | TextFlash | BallFlash;
export type RunStats = { export type RunStats = {
started: number; started: number;
levelsPlayed: number; levelsPlayed: number;
runTime: number; runTime: number;
coins_spawned: number; coins_spawned: number;
score: number; score: number;
bricks_broken: number; bricks_broken: number;
misses: number; misses: number;
balls_lost: number; balls_lost: number;
puck_bounces: number; puck_bounces: number;
upgrades_picked: number; upgrades_picked: number;
max_combo: number; max_combo: number;
max_level: number; max_level: number;
}; };
export type PerksMap = { export type PerksMap = {
[k in PerkId]: number; [k in PerkId]: number;
}; };
export type RunHistoryItem = RunStats & { export type RunHistoryItem = RunStats & {
perks?: PerksMap; perks?: PerksMap;
appVersion?: string; appVersion?: string;
}; };
export type GameState = {
// Width of the canvas element in pixels
canvasWidth: number;
// Height of the canvas element in pixels
canvasHeight: number;
// Distance between the left of the canvas and the left of the leftmost brick, in pixels
offsetX: number;
// Distance between the left of the canvas and the left border of the game area, in pixels.
// Can be 0 when no border is shown
offsetXRoundedDown: number;
// Width of the bricks area, in pixels
gameZoneWidth: number;
// Width of the game area between the left and right borders, in pixels
gameZoneWidthRoundedUp: number;
// Height of the play area, between the top of the canvas and the bottom of the puck.
// Does not include the finger zone on mobile.
gameZoneHeight: number;
// Size of one brick in pixels
brickWidth: number;
// Size of the current level's grid
gridSize: number;
// 0 based index of the current level in the run (level X / 7)
currentLevel: number;
// 10 levels selected randomly at start for the run
runLevels: Level[];
// Width of the puck in pixels, changed by some perks and resizes
puckWidth: number;
// perks the user currently has
perks: PerksMap;
// Base speed of the ball in pixels/tick
baseSpeed: number;
// Score multiplier
combo: number;
// Whether the game is running or paused
running: boolean;
// Position of the center of the puck on the canvas in pixels, from the left of the canvas.
puckPosition: number;
// Will be set if the game is about to be paused. Game pause is delayed by a few milliseconds if you pause a few times in a run,
// to avoid abuse of the "release to pause" feature on mobile.
pauseTimeout: NodeJS.Timeout | null;
// Whether the game should be rendered at the next tick, even if the game is paused
needsRender: boolean;
// Current run score
score: number;
// levelTime of the last explosion, for screen shake
lastExplosion: number;
// High score at the beginning of the run
highScore: number;
// Balls currently in game, game over if it's empty
balls: Ball[];
// Color of the balls, can be changed by some perks
ballsColor: colorString;
// Array of bricks to display. 'black' means bomb. '' means no brick.
bricks: colorString[];
flashes: Flash[];
coins: Coin[];
levelStartScore: number;
levelMisses: number;
levelSpawnedCoins: number;
lastPlayedCoinGrab: number;
MAX_COINS: number;
MAX_PARTICLES: number;
puckColor: colorString;
ballSize: number;
coinSize: number;
puckHeight: number;
totalScoreAtRunStart: number;
}
export type RunParams={
level?: string;
levelToAvoid?:string;
perks?:Partial<PerksMap>
}