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] 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
- 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

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:*",
"dev:game-fe": "parcel src/*.html --lazy --no-hmr",
"dev:editor-be": "nodemon editserver.js --watch editserver.js",
"dev:watch-tests": "jest watch",
"build": "rm -f dist/* && parcel build src/index.html"
"test": "jest --watch",
"build": "npx jest && rm -f dist/* && parcel build src/index.html"
},
"browserslist": "since 2009",
"author": "Renan LE CARO",
@ -26,5 +26,11 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"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',()=>{
expect(resizeLevel({
name:'',
bricks:'AAAA',
size:2,
svg:null,
color:''
}, 1)).toBe({bricks:'AA_AA____',size:3});
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});
})
})
describe('moveLevel', () => {
it('should do nothing when coords are 0/0', () => {
expect(moveLevel(baseLevel, 0, 0)).toStrictEqual({bricks: 'AAAA'});
})
it('should move right', () => {
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 {getLevelBackground, hashCode} from "./getLevelBackground";
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";
const backgrounds = _backgrounds as string[];
@ -14,17 +14,6 @@ const palette = _palette as Palette;
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() {
const [selected, setSelected] = useState('W')
@ -33,12 +22,29 @@ function App() {
const updateLevel = useCallback((index: number, change: Partial<RawLevel>) => {
setLevels(list => list.map((l, li) => li === index ? {...l, ...change} : l))
}, []);
const deleteLevel = useCallback((li: number) => {
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('')}>
<div id={"levels"}>
{

View file

@ -3,10 +3,10 @@ import {RawLevel} from "./types";
export function resizeLevel(level: RawLevel, sizeDelta: number) {
const {size, bricks} = level
const newSize = Math.max(1, size + sizeDelta)
const newBricks = new Array(newSize * newSize).fill('_')
for (let x = 0; x < Math.min(size, newSize); x++) {
for (let y = 0; y < Math.min(size, newSize); y++) {
newBricks[y * newSize + x] = bricks.split('')[y * size + x] || '_'
const newBricks = []
for (let x = 0; x < newSize; x++) {
for (let y = 0; y < newSize; y++) {
newBricks[y * newSize + x] = brickAt(level, x, y )
}
}
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) {
const {size, bricks} = level
const {size} = level
const newBricks = new Array(size * size).fill('_')
for (let x = 0; x < size; x++) {
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 {
@ -29,12 +33,15 @@ export function moveLevel(level: RawLevel, dx: number, dy: number) {
}
export function setBrick(level: RawLevel, index: number, colorCode: string) {
let bricksString = level.bricks.slice(0, level.size * level.size)
if (bricksString.length < level.size * level.size) {
bricksString += '_'.repeat(level.size * level.size - bricksString.length)
const {size} = level
const newBricks=[]
for (let x = 0; x < size; x++) {
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 {
gameZoneWidthRoundedUp,
gameState,
isSettingOn,
offsetX,
offsetXRoundedDown,
} from "./game";
export const sounds = {
@ -160,7 +158,7 @@ function createExplosionSound(pan = 0.5) {
function pixelsToPan(pan: number) {
return Math.max(
0,
Math.min(1, (pan - offsetXRoundedDown) / gameZoneWidthRoundedUp),
Math.min(1, (pan - gameState.offsetXRoundedDown) / gameState.gameZoneWidthRoundedUp),
);
}

80
src/types.d.ts vendored
View file

@ -1,4 +1,4 @@
import { rawUpgrades } from "./rawUpgrades";
import {rawUpgrades} from "./rawUpgrades";
export type colorString = string;
@ -110,6 +110,7 @@ interface BaseFlash {
x: number;
y: number;
}
interface ParticleFlash extends BaseFlash {
type: "particle";
vx: number;
@ -151,3 +152,80 @@ export type RunHistoryItem = RunStats & {
perks?: PerksMap;
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>
}