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

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({
name:'',
bricks:'AAAA',
size:2,
svg:null,
color:''
}, 1)).toBe({bricks:'AA_AA____',size:3});
})
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),
);
}

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 RawLevel = {
name: string;
size: number;
bricks: string;
svg: number | null;
color: string;
name: string;
size: number;
bricks: string;
svg: number | null;
color: string;
};
export type Level = {
name: string;
size: number;
bricks: colorString[];
svg: string;
color: string;
threshold: number;
sortKey: number;
name: string;
size: number;
bricks: colorString[];
svg: string;
color: string;
threshold: number;
sortKey: number;
};
export type Palette = { [k: string]: string };
export type Upgrade = {
threshold: number;
giftable: boolean;
id: PerkId;
name: string;
icon: string;
max: number;
help: (lvl: number) => string;
fullHelp: string;
requires: PerkId | "";
threshold: number;
giftable: boolean;
id: PerkId;
name: string;
icon: string;
max: number;
help: (lvl: number) => string;
fullHelp: string;
requires: PerkId | "";
};
export type PerkId = (typeof rawUpgrades)[number]["id"];
declare global {
interface Window {
webkitAudioContext?: typeof AudioContext;
}
interface Window {
webkitAudioContext?: typeof AudioContext;
}
interface Document {
webkitFullscreenEnabled?: boolean;
webkitCancelFullScreen?: () => void;
}
interface Document {
webkitFullscreenEnabled?: boolean;
webkitCancelFullScreen?: () => void;
}
interface Element {
webkitRequestFullscreen: typeof Element.requestFullscreen;
}
interface Element {
webkitRequestFullscreen: typeof Element.requestFullscreen;
}
interface MediaStream {
// https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html
// On firefox, the capture stream has the requestFrame option
// instead of the track, go figure
requestFrame?: () => void;
}
interface MediaStream {
// https://devdoc.net/web/developer.mozilla.org/en-US/docs/Web/API/CanvasCaptureMediaStream.html
// On firefox, the capture stream has the requestFrame option
// instead of the track, go figure
requestFrame?: () => void;
}
}
export type BallLike = {
x: number;
y: number;
vx?: number;
vy?: number;
x: number;
y: number;
vx?: number;
vy?: number;
};
export type Coin = {
points: number;
color: colorString;
x: number;
y: number;
previousX: number;
previousY: number;
vx: number;
vy: number;
sx: number;
sy: number;
a: number;
sa: number;
weight: number;
destroyed?: boolean;
coloredABrick?: boolean;
points: number;
color: colorString;
x: number;
y: number;
previousX: number;
previousY: number;
vx: number;
vy: number;
sx: number;
sy: number;
a: number;
sa: number;
weight: number;
destroyed?: boolean;
coloredABrick?: boolean;
};
export type Ball = {
x: number;
previousX: number;
y: number;
previousY: number;
vx: number;
vy: number;
previousVX: number;
previousVY: number;
sx: number;
sy: number;
sparks: number;
piercedSinceBounce: number;
hitSinceBounce: number;
hitItem: { index: number; color: string }[];
bouncesList: { x: number; y: number }[];
sapperUses: number;
destroyed?: boolean;
x: number;
previousX: number;
y: number;
previousY: number;
vx: number;
vy: number;
previousVX: number;
previousVY: number;
sx: number;
sy: number;
sparks: number;
piercedSinceBounce: number;
hitSinceBounce: number;
hitItem: { index: number; color: string }[];
bouncesList: { x: number; y: number }[];
sapperUses: number;
destroyed?: boolean;
};
interface BaseFlash {
time: number;
color: colorString;
duration: number;
size: number;
destroyed?: boolean;
x: number;
y: number;
time: number;
color: colorString;
duration: number;
size: number;
destroyed?: boolean;
x: number;
y: number;
}
interface ParticleFlash extends BaseFlash {
type: "particle";
vx: number;
vy: number;
ethereal: boolean;
type: "particle";
vx: number;
vy: number;
ethereal: boolean;
}
interface TextFlash extends BaseFlash {
type: "text";
text: string;
type: "text";
text: string;
}
interface BallFlash extends BaseFlash {
type: "ball";
type: "ball";
}
export type Flash = ParticleFlash | TextFlash | BallFlash;
export type RunStats = {
started: number;
levelsPlayed: number;
runTime: number;
coins_spawned: number;
score: number;
bricks_broken: number;
misses: number;
balls_lost: number;
puck_bounces: number;
upgrades_picked: number;
max_combo: number;
max_level: number;
started: number;
levelsPlayed: number;
runTime: number;
coins_spawned: number;
score: number;
bricks_broken: number;
misses: number;
balls_lost: number;
puck_bounces: number;
upgrades_picked: number;
max_combo: number;
max_level: number;
};
export type PerksMap = {
[k in PerkId]: number;
[k in PerkId]: number;
};
export type RunHistoryItem = RunStats & {
perks?: PerksMap;
appVersion?: string;
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>
}