nuts / shampoo / files / src / physics.ts
Branch shampoo · default branch · all branches
physics.ts
44eb5832 2026-04-12 Pavel Safronov | 1 | import Matter from 'matter-js' |
| 2 | import { CONFIG } from './config' | |
98ba5504 2026-04-13 Pavel Safronov | 3 | import { vibrate, vibrateWall } from './vibration' |
aa6e5a99 2026-04-13 Pavel Safronov | 4 | import { playImpact, type ImpactKind } from './sound' |
f1262fd9 2026-04-12 Pavel Safronov | 5 | import { dbg } from './debug' |
98ba5504 2026-04-13 Pavel Safronov | 6 | import { ROCK_TEMPLATES } from './rockTemplates' |
44eb5832 2026-04-12 Pavel Safronov | 7 | |
f1262fd9 2026-04-12 Pavel Safronov | 8 | const { Engine, Bodies, Body, Composite, Events, Vector } = Matter |
44eb5832 2026-04-12 Pavel Safronov | 9 | |
5dba9120 2026-04-13 Pavel Safronov | 10 | // ── Item label set (single source of truth for "is this an item body?") ──── |
a2e3ea1a 2026-04-13 Pavel Safronov | 11 | export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] as const) |
5dba9120 2026-04-13 Pavel Safronov | 12 | |
44eb5832 2026-04-12 Pavel Safronov | 13 | // ── Types ────────────────────────────────────────────────────────────────── |
| 14 | ||
| 15 | export interface NutBody { | |
| 16 | body: Matter.Body | |
| 17 | radius: number | |
| 18 | colorIndex: number | |
a2e3ea1a 2026-04-13 Pavel Safronov | 19 | kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin' |
5dba9120 2026-04-13 Pavel Safronov | 20 | gemVariant: number // gems: 0=diamond…4=amethyst; always 0 for non-gems |
| 21 | state: number // rocks: damage (0→10+); balloons/stressballs: squish (0→1); 0 for others | |
98ba5504 2026-04-13 Pavel Safronov | 22 | templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks |
| 23 | toughness: number // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks | |
44eb5832 2026-04-12 Pavel Safronov | 24 | } |
| 25 | ||
| 26 | export interface PhysicsWorld { | |
| 27 | engine: Matter.Engine | |
| 28 | nuts: NutBody[] | |
aa6e5a99 2026-04-13 Pavel Safronov | 29 | onRemoved: ((kind: NutBody['kind'], radius: number) => void) | null |
f1262fd9 2026-04-12 Pavel Safronov | 30 | addNut: (x: number, y: number, nutRadius?: number) => void |
| 31 | addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void | |
| 32 | addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void | |
cfe80f49 2026-04-13 Pavel Safronov | 33 | addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void |
a2e3ea1a 2026-04-13 Pavel Safronov | 34 | addCoin: (x: number, y: number, radius?: number, materialIdx?: number) => void |
a2b3b1f4 2026-04-12 Pavel Safronov | 35 | addMetalNut: (x: number, y: number, nutRadius?: number) => void |
74d506c6 2026-04-12 Pavel Safronov | 36 | addRock: (x: number, y: number, radius?: number) => void |
f1262fd9 2026-04-12 Pavel Safronov | 37 | removeAt: (x: number, y: number) => boolean |
| 38 | clearNuts: () => void | |
44eb5832 2026-04-12 Pavel Safronov | 39 | resize: (width: number, height: number) => void |
| 40 | applyShakeForce: (intensity: number) => void | |
| 41 | setGravity: (gx: number, gy: number) => void | |
| 42 | step: (deltaMs: number) => void | |
| 43 | destroy: () => void | |
| 44 | } | |
| 45 | ||
| 46 | // ── Helpers ──────────────────────────────────────────────────────────────── | |
| 47 | ||
| 48 | function buildWalls(width: number, height: number): Matter.Body[] { | |
| 49 | const t = CONFIG.wallThickness | |
a2b3b1f4 2026-04-12 Pavel Safronov | 50 | const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.9, frictionStatic: 0.3 } |
44eb5832 2026-04-12 Pavel Safronov | 51 | return [ |
| 52 | Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts), // top | |
| 53 | Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts), // bottom | |
| 54 | Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts), // left | |
| 55 | Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts), // right | |
| 56 | ] | |
| 57 | } | |
| 58 | ||
f1262fd9 2026-04-12 Pavel Safronov | 59 | function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { |
44eb5832 2026-04-12 Pavel Safronov | 60 | const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance |
f1262fd9 2026-04-12 Pavel Safronov | 61 | const radius = nutRadius * variance |
| 62 | const ry = radius / CONFIG.nutAspectRatio | |
| 63 | ||
| 64 | // Convex ellipse polygon approximation (20 vertices) | |
| 65 | const N = 20 | |
| 66 | const verts: Matter.Vector[] = [] | |
| 67 | for (let i = 0; i < N; i++) { | |
| 68 | const a = (i / N) * Math.PI * 2 | |
| 69 | verts.push(Vector.create(Math.cos(a) * radius, Math.sin(a) * ry)) | |
| 70 | } | |
| 71 | ||
| 72 | const body = Bodies.fromVertices(x, y, [verts], { | |
fd4adeec 2026-04-12 Pavel Safronov | 73 | density: 0.0006, // walnut ~0.6 g/cm³ — lighter than water |
44eb5832 2026-04-12 Pavel Safronov | 74 | restitution: CONFIG.restitution, |
| 75 | friction: CONFIG.friction, | |
| 76 | frictionAir: CONFIG.frictionAir, | |
| 77 | label: 'nut', | |
| 78 | }) | |
5dba9120 2026-04-13 Pavel Safronov | 79 | return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
f1262fd9 2026-04-12 Pavel Safronov | 80 | } |
| 81 | ||
a2b3b1f4 2026-04-12 Pavel Safronov | 82 | function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { |
| 83 | const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance | |
| 84 | const radius = nutRadius * variance | |
fd4adeec 2026-04-12 Pavel Safronov | 85 | const body = Bodies.polygon(x, y, 6, radius, { |
| 86 | density: 0.006, // ~6× default (0.001) — steel-like weight | |
a2b3b1f4 2026-04-12 Pavel Safronov | 87 | restitution: 0.92, |
| 88 | friction: 0, | |
| 89 | frictionStatic: 0, | |
| 90 | frictionAir: 0.0008, | |
| 91 | label: 'metalnut', | |
| 92 | }) | |
5dba9120 2026-04-13 Pavel Safronov | 93 | return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
a2b3b1f4 2026-04-12 Pavel Safronov | 94 | } |
| 95 | ||
f1262fd9 2026-04-12 Pavel Safronov | 96 | const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant |
| 97 | export const GEM_VARIANT_COUNT = GEM_SIDES.length | |
| 98 | export const BALLOON_COLOR_COUNT = 7 | |
cfe80f49 2026-04-13 Pavel Safronov | 99 | export const STRESSBALL_COLOR_COUNT = 6 |
a2e3ea1a 2026-04-13 Pavel Safronov | 100 | export const COIN_MATERIAL_COUNT = 6 |
| 101 | export const COIN_ANIMAL_COUNT = 10 | |
| 102 | ||
| 103 | // Per-material physics differ: density drives heft, restitution the bounce character | |
| 104 | const COIN_PHYSICS = [ | |
| 105 | { density: 0.0095, restitution: 0.42 }, // 0 gold | |
| 106 | { density: 0.0070, restitution: 0.45 }, // 1 silver | |
| 107 | { density: 0.0062, restitution: 0.38 }, // 2 copper | |
| 108 | { density: 0.0058, restitution: 0.35 }, // 3 bronze | |
| 109 | { density: 0.0062, restitution: 0.40 }, // 4 nickel | |
| 110 | { density: 0.0140, restitution: 0.40 }, // 5 platinum | |
| 111 | ] as const | |
cfe80f49 2026-04-13 Pavel Safronov | 112 | |
f1262fd9 2026-04-12 Pavel Safronov | 113 | function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody { |
| 114 | const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance | |
| 115 | const actualRadius = radius * variance | |
| 116 | const sides = GEM_SIDES[gemVariant % GEM_SIDES.length] | |
| 117 | const body = Bodies.polygon(x, y, sides, actualRadius, { | |
fd4adeec 2026-04-12 Pavel Safronov | 118 | density: 0.0035, // crystal/gemstone ~3.5 g/cm³ |
a2b3b1f4 2026-04-12 Pavel Safronov | 119 | restitution: 0.95, |
| 120 | friction: 0, | |
| 121 | frictionStatic: 0, | |
| 122 | frictionAir: 0.0005, | |
f1262fd9 2026-04-12 Pavel Safronov | 123 | label: 'gem', |
| 124 | }) | |
5dba9120 2026-04-13 Pavel Safronov | 125 | return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, state: 0, templateIdx: 0, toughness: 1 } |
f1262fd9 2026-04-12 Pavel Safronov | 126 | } |
| 127 | ||
cfe80f49 2026-04-13 Pavel Safronov | 128 | const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon |
| 129 | const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier | |
| 130 | ||
f1262fd9 2026-04-12 Pavel Safronov | 131 | function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { |
fd4adeec 2026-04-12 Pavel Safronov | 132 | const variance = 0.5 + Math.random() * 1.0 // 0.5× to 1.5× base radius |
f1262fd9 2026-04-12 Pavel Safronov | 133 | const actualRadius = radius * variance |
| 134 | const body = Bodies.circle(x, y, actualRadius, { | |
fd4adeec 2026-04-12 Pavel Safronov | 135 | density: 0.00005, // air-filled balloon — nearly weightless |
cfe80f49 2026-04-13 Pavel Safronov | 136 | restitution: 0.95, // very bouncy |
| 137 | friction: 0.005, | |
| 138 | frictionAir: 0.006, | |
f1262fd9 2026-04-12 Pavel Safronov | 139 | label: 'balloon', |
| 140 | }) | |
5dba9120 2026-04-13 Pavel Safronov | 141 | return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
44eb5832 2026-04-12 Pavel Safronov | 142 | } |
| 143 | ||
cfe80f49 2026-04-13 Pavel Safronov | 144 | const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon |
| 145 | ||
a2e3ea1a 2026-04-13 Pavel Safronov | 146 | function spawnCoin(x: number, y: number, radius: number, materialIdx: number): NutBody { |
| 147 | const mat = COIN_PHYSICS[materialIdx % COIN_PHYSICS.length] | |
| 148 | const variance = 0.55 + Math.random() * 0.85 // 0.55× to 1.4× — penny to dollar coin range | |
| 149 | const actualRadius = radius * variance | |
| 150 | const body = Bodies.circle(x, y, actualRadius, { | |
| 151 | density: mat.density, | |
| 152 | restitution: mat.restitution, | |
| 153 | friction: 0.30, | |
| 154 | frictionAir: 0.0012, | |
| 155 | label: 'coin', | |
| 156 | }) | |
| 157 | const mi = materialIdx % COIN_MATERIAL_COUNT | |
| 158 | const animalIdx = Math.floor(Math.random() * COIN_ANIMAL_COUNT) | |
| 159 | return { body, radius: actualRadius, colorIndex: mi, kind: 'coin', gemVariant: mi, state: 0, templateIdx: animalIdx, toughness: 1 } | |
| 160 | } | |
| 161 | ||
cfe80f49 2026-04-13 Pavel Safronov | 162 | function spawnStressBall(x: number, y: number, radius: number, colorIndex: number): NutBody { |
| 163 | const variance = 0.7 + Math.random() * 0.6 // 0.7× to 1.3× base radius | |
| 164 | const actualRadius = radius * variance | |
| 165 | const body = Bodies.circle(x, y, actualRadius, { | |
| 166 | density: 0.0006, // denser than balloon — has some heft | |
| 167 | restitution: 0.55, // bouncy but not as floaty as balloon | |
| 168 | friction: 0.4, // grippy rubber | |
| 169 | frictionAir: 0.015, | |
| 170 | label: 'stressball', | |
| 171 | }) | |
5dba9120 2026-04-13 Pavel Safronov | 172 | return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
cfe80f49 2026-04-13 Pavel Safronov | 173 | } |
| 174 | ||
44eb5832 2026-04-12 Pavel Safronov | 175 | // ── Factory ──────────────────────────────────────────────────────────────── |
| 176 | ||
74d506c6 2026-04-12 Pavel Safronov | 177 | const ROCK_COLORS_COUNT = 4 |
| 178 | const ROCK_SPLIT_SPEED = 6 // min px/tick to count as a hit | |
| 179 | const ROCK_HITS_TO_SPLIT = 10 // hits required to break | |
| 180 | ||
| 181 | function spawnRock(x: number, y: number, targetRadius: number, colorIndex: number): NutBody { | |
| 182 | const variance = 0.85 + Math.random() * 0.3 | |
| 183 | const radius = targetRadius * variance | |
98ba5504 2026-04-13 Pavel Safronov | 184 | const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length) |
| 185 | const template = ROCK_TEMPLATES[templateIdx] | |
74d506c6 2026-04-12 Pavel Safronov | 186 | |
98ba5504 2026-04-13 Pavel Safronov | 187 | const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius)) |
74d506c6 2026-04-12 Pavel Safronov | 188 | const body = Bodies.fromVertices(x, y, [verts], { |
98ba5504 2026-04-13 Pavel Safronov | 189 | density: 0.0025, |
| 190 | restitution: 0.25, | |
74d506c6 2026-04-12 Pavel Safronov | 191 | friction: 0.65, |
| 192 | frictionAir: 0.001, | |
| 193 | label: 'rock', | |
| 194 | }) | |
98ba5504 2026-04-13 Pavel Safronov | 195 | // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard) |
| 196 | const toughness = Math.pow(2, (Math.random() - 0.5) * 2) // 2^[-1, 1] = 0.5 … 2.0 | |
5dba9120 2026-04-13 Pavel Safronov | 197 | return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, state: 0, templateIdx, toughness } |
74d506c6 2026-04-12 Pavel Safronov | 198 | } |
| 199 | ||
44eb5832 2026-04-12 Pavel Safronov | 200 | export function createPhysicsWorld(width: number, height: number): PhysicsWorld { |
| 201 | const engine = Engine.create() | |
| 202 | engine.gravity.x = 0 | |
| 203 | engine.gravity.y = CONFIG.gravityScale | |
| 204 | ||
| 205 | let walls = buildWalls(width, height) | |
| 206 | let nuts: NutBody[] = [] | |
5dba9120 2026-04-13 Pavel Safronov | 207 | const nutBodyMap = new Map<Matter.Body, NutBody>() |
74d506c6 2026-04-12 Pavel Safronov | 208 | let splitQueue: NutBody[] = [] |
| 209 | const splittingIds = new Set<number>() | |
cfe80f49 2026-04-13 Pavel Safronov | 210 | let popQueue: NutBody[] = [] |
| 211 | const poppingIds = new Set<number>() | |
44eb5832 2026-04-12 Pavel Safronov | 212 | |
| 213 | Composite.add(engine.world, walls) | |
| 214 | ||
cfe80f49 2026-04-13 Pavel Safronov | 215 | // ── Balloon buoyancy + squish decay ──────────────────────────────────── |
| 216 | // Balloons float against gravity (2.2× lift) and their squish decays each tick. | |
f1262fd9 2026-04-12 Pavel Safronov | 217 | Events.on(engine, 'beforeUpdate', () => { |
| 218 | const scale = engine.gravity.scale * 2.2 | |
cfe80f49 2026-04-13 Pavel Safronov | 219 | for (const nb of nuts) { |
| 220 | if (nb.kind === 'balloon') { | |
| 221 | // Decay squish (hitCount) toward zero each tick | |
5dba9120 2026-04-13 Pavel Safronov | 222 | if (nb.state > 0) { |
| 223 | nb.state *= BALLOON_SQUISH_DECAY | |
| 224 | if (nb.state < 0.005) nb.state = 0 | |
cfe80f49 2026-04-13 Pavel Safronov | 225 | } |
| 226 | // Buoyancy | |
| 227 | Body.applyForce(nb.body, nb.body.position, { | |
| 228 | x: -nb.body.mass * engine.gravity.x * scale, | |
| 229 | y: -nb.body.mass * engine.gravity.y * scale, | |
f1262fd9 2026-04-12 Pavel Safronov | 230 | }) |
cfe80f49 2026-04-13 Pavel Safronov | 231 | } else if (nb.kind === 'stressball') { |
5dba9120 2026-04-13 Pavel Safronov | 232 | if (nb.state > 0) { |
| 233 | nb.state *= STRESSBALL_SQUISH_DECAY | |
| 234 | if (nb.state < 0.005) nb.state = 0 | |
cfe80f49 2026-04-13 Pavel Safronov | 235 | } |
f1262fd9 2026-04-12 Pavel Safronov | 236 | } |
| 237 | } | |
| 238 | }) | |
| 239 | ||
44eb5832 2026-04-12 Pavel Safronov | 240 | // ── Collision haptics ────────────────────────────────────────────────── |
aa6e5a99 2026-04-13 Pavel Safronov | 241 | |
| 242 | function impactSoundKind(labelA: string, labelB = ''): ImpactKind { | |
| 243 | const has = (l: string) => labelA === l || labelB === l | |
| 244 | if (has('rock')) return 'rock' | |
| 245 | if (has('metalnut')) return 'metalnut' | |
| 246 | if (has('coin')) return 'coin' | |
| 247 | if (has('gem')) return 'gem' | |
| 248 | if (has('stressball')) return 'stressball' | |
| 249 | if (has('balloon')) return 'balloon' | |
| 250 | return 'nut' | |
| 251 | } | |
| 252 | ||
44eb5832 2026-04-12 Pavel Safronov | 253 | Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => { |
| 254 | for (const pair of event.pairs) { | |
| 255 | const aWall = pair.bodyA.label === 'wall' | |
| 256 | const bWall = pair.bodyB.label === 'wall' | |
5dba9120 2026-04-13 Pavel Safronov | 257 | const aNut = ITEM_LABELS.has(pair.bodyA.label as never) |
| 258 | const bNut = ITEM_LABELS.has(pair.bodyB.label as never) | |
a2b3b1f4 2026-04-12 Pavel Safronov | 259 | |
| 260 | const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut' | |
| 261 | const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut' | |
74d506c6 2026-04-12 Pavel Safronov | 262 | const aRock = pair.bodyA.label === 'rock' |
| 263 | const bRock = pair.bodyB.label === 'rock' | |
a2b3b1f4 2026-04-12 Pavel Safronov | 264 | const gemInvolved = aGem || bGem |
f1262fd9 2026-04-12 Pavel Safronov | 265 | |
74d506c6 2026-04-12 Pavel Safronov | 266 | // Queue rock splits before vibration so we have speed info |
| 267 | const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) | |
98ba5504 2026-04-13 Pavel Safronov | 268 | if (speed > ROCK_SPLIT_SPEED) { |
| 269 | const tryQueueSplit = (rockBody: Matter.Body, otherBody: Matter.Body) => { | |
| 270 | if (rockBody.label !== 'rock') return | |
| 271 | if (splittingIds.has(rockBody.id)) return | |
| 272 | const nb = nuts.find(n => n.body === rockBody) | |
74d506c6 2026-04-12 Pavel Safronov | 273 | if (!nb) return |
98ba5504 2026-04-13 Pavel Safronov | 274 | // Damage = excess kinetic energy of impactor relative to rock mass. |
| 275 | // Normalised so 10 hits at ROCK_SPLIT_SPEED*1.5 from equal mass ≈ 10. | |
| 276 | const excess = speed - ROCK_SPLIT_SPEED | |
| 277 | const massRatio = otherBody.mass / rockBody.mass | |
5dba9120 2026-04-13 Pavel Safronov | 278 | nb.state += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness) |
| 279 | if (nb.state < ROCK_HITS_TO_SPLIT) return | |
98ba5504 2026-04-13 Pavel Safronov | 280 | splittingIds.add(rockBody.id) |
74d506c6 2026-04-12 Pavel Safronov | 281 | splitQueue.push(nb) |
| 282 | } | |
98ba5504 2026-04-13 Pavel Safronov | 283 | if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB) |
| 284 | if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA) | |
74d506c6 2026-04-12 Pavel Safronov | 285 | } |
| 286 | ||
cfe80f49 2026-04-13 Pavel Safronov | 287 | // ── Balloon squish / pop ─────────────────────────────────── |
| 288 | const aBalloon = pair.bodyA.label === 'balloon' | |
| 289 | const bBalloon = pair.bodyB.label === 'balloon' | |
| 290 | const aStress = pair.bodyA.label === 'stressball' | |
| 291 | const bStress = pair.bodyB.label === 'stressball' | |
| 292 | if (aBalloon || bBalloon) { | |
| 293 | const applyPressure = (balloonBody: Matter.Body) => { | |
| 294 | if (poppingIds.has(balloonBody.id)) return | |
5dba9120 2026-04-13 Pavel Safronov | 295 | const nb = nutBodyMap.get(balloonBody) |
cfe80f49 2026-04-13 Pavel Safronov | 296 | if (!nb) return |
| 297 | // Pressure scales as speed^1.5 so gentle bounces squish, hard hits pop | |
| 298 | const pressure = Math.pow(speed / BALLOON_POP_SPEED, 1.5) | |
5dba9120 2026-04-13 Pavel Safronov | 299 | nb.state = Math.min(nb.state + pressure, 1.5) |
| 300 | if (nb.state >= 1.0) { | |
cfe80f49 2026-04-13 Pavel Safronov | 301 | poppingIds.add(balloonBody.id) |
| 302 | popQueue.push(nb) | |
| 303 | } | |
| 304 | } | |
| 305 | if (aBalloon) applyPressure(pair.bodyA) | |
| 306 | if (bBalloon) applyPressure(pair.bodyB) | |
| 307 | } | |
| 308 | ||
| 309 | // ── Stress ball squish (no pop) ───────────────────────────── | |
| 310 | if (aStress || bStress) { | |
| 311 | const applySquish = (sbBody: Matter.Body) => { | |
5dba9120 2026-04-13 Pavel Safronov | 312 | const nb = nutBodyMap.get(sbBody) |
cfe80f49 2026-04-13 Pavel Safronov | 313 | if (!nb) return |
| 314 | const pressure = Math.pow(speed / 14, 1.2) | |
5dba9120 2026-04-13 Pavel Safronov | 315 | nb.state = Math.min(nb.state + pressure, 1.0) |
cfe80f49 2026-04-13 Pavel Safronov | 316 | } |
| 317 | if (aStress) applySquish(pair.bodyA) | |
| 318 | if (bStress) applySquish(pair.bodyB) | |
| 319 | } | |
| 320 | ||
f1262fd9 2026-04-12 Pavel Safronov | 321 | if (aNut && bNut) { |
74d506c6 2026-04-12 Pavel Safronov | 322 | // Body-body collision |
f1262fd9 2026-04-12 Pavel Safronov | 323 | dbg('nut-nut speed:', speed.toFixed(2)) |
| 324 | if (speed > CONFIG.impactThreshold) { | |
aa6e5a99 2026-04-13 Pavel Safronov | 325 | const sndKind = impactSoundKind(pair.bodyA.label, pair.bodyB.label) |
| 326 | const nbA = nutBodyMap.get(pair.bodyA) | |
| 327 | const nbB = nutBodyMap.get(pair.bodyB) | |
| 328 | const avgRadius = ((nbA?.radius ?? 60) + (nbB?.radius ?? 60)) / 2 | |
74d506c6 2026-04-12 Pavel Safronov | 329 | if (aRock || bRock) { |
| 330 | const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) | |
| 331 | vibrate(Math.round(12 + t * 28)) | |
aa6e5a99 2026-04-13 Pavel Safronov | 332 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
74d506c6 2026-04-12 Pavel Safronov | 333 | } else if (aGem && bGem) { |
a2b3b1f4 2026-04-12 Pavel Safronov | 334 | const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1) |
| 335 | vibrate(Math.round(8 + t * 18)) | |
aa6e5a99 2026-04-13 Pavel Safronov | 336 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
a2b3b1f4 2026-04-12 Pavel Safronov | 337 | } else if (gemInvolved) { |
| 338 | const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) | |
| 339 | vibrate(Math.round(10 + t * 22)) | |
aa6e5a99 2026-04-13 Pavel Safronov | 340 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
a2b3b1f4 2026-04-12 Pavel Safronov | 341 | } else { |
| 342 | const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) | |
| 343 | vibrate(Math.round(10 + t * 30)) | |
aa6e5a99 2026-04-13 Pavel Safronov | 344 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
a2b3b1f4 2026-04-12 Pavel Safronov | 345 | } |
f1262fd9 2026-04-12 Pavel Safronov | 346 | } |
| 347 | } else if (aNut || bNut) { | |
74d506c6 2026-04-12 Pavel Safronov | 348 | // Body-wall collision |
f1262fd9 2026-04-12 Pavel Safronov | 349 | const nutBody = aWall ? pair.bodyB : pair.bodyA |
5dba9120 2026-04-13 Pavel Safronov | 350 | if (!ITEM_LABELS.has(nutBody.label as never)) continue |
74d506c6 2026-04-12 Pavel Safronov | 351 | const wallSpeed = nutBody.speed |
aa6e5a99 2026-04-13 Pavel Safronov | 352 | const itemRadius = nutBodyMap.get(nutBody)?.radius ?? 60 |
74d506c6 2026-04-12 Pavel Safronov | 353 | dbg('wall hit speed:', wallSpeed.toFixed(2)) |
| 354 | if (wallSpeed > CONFIG.impactThreshold) { | |
aa6e5a99 2026-04-13 Pavel Safronov | 355 | const sndKind = impactSoundKind(nutBody.label) |
74d506c6 2026-04-12 Pavel Safronov | 356 | if (nutBody.label === 'rock') { |
| 357 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1) | |
98ba5504 2026-04-13 Pavel Safronov | 358 | vibrateWall(Math.round(15 + t * 30)) |
aa6e5a99 2026-04-13 Pavel Safronov | 359 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
74d506c6 2026-04-12 Pavel Safronov | 360 | } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { |
| 361 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1) | |
98ba5504 2026-04-13 Pavel Safronov | 362 | vibrateWall(Math.round(15 + t * 35)) |
aa6e5a99 2026-04-13 Pavel Safronov | 363 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
a2b3b1f4 2026-04-12 Pavel Safronov | 364 | } else { |
74d506c6 2026-04-12 Pavel Safronov | 365 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1) |
98ba5504 2026-04-13 Pavel Safronov | 366 | vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) |
aa6e5a99 2026-04-13 Pavel Safronov | 367 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
a2b3b1f4 2026-04-12 Pavel Safronov | 368 | } |
f1262fd9 2026-04-12 Pavel Safronov | 369 | } |
44eb5832 2026-04-12 Pavel Safronov | 370 | } |
| 371 | } | |
| 372 | }) | |
| 373 | ||
| 374 | // ── Public API ───────────────────────────────────────────────────────── | |
| 375 | ||
f1262fd9 2026-04-12 Pavel Safronov | 376 | function addNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void { |
| 377 | const colorIndex = nuts.length | |
| 378 | const nut = spawnNut(x, y, nutRadius, colorIndex) | |
| 379 | Body.setAngle(nut.body, Math.random() * Math.PI * 2) | |
| 380 | nuts.push(nut) | |
5dba9120 2026-04-13 Pavel Safronov | 381 | nutBodyMap.set(nut.body, nut) |
f1262fd9 2026-04-12 Pavel Safronov | 382 | Composite.add(engine.world, nut.body) |
| 383 | } | |
| 384 | ||
| 385 | function addGem(x: number, y: number, radius: number = CONFIG.nutBaseRadius, gemVariant?: number): void { | |
| 386 | const variant = gemVariant ?? Math.floor(Math.random() * GEM_VARIANT_COUNT) | |
| 387 | const gem = spawnGem(x, y, radius, variant) | |
| 388 | Body.setAngle(gem.body, Math.random() * Math.PI * 2) | |
| 389 | nuts.push(gem) | |
5dba9120 2026-04-13 Pavel Safronov | 390 | nutBodyMap.set(gem.body, gem) |
f1262fd9 2026-04-12 Pavel Safronov | 391 | Composite.add(engine.world, gem.body) |
| 392 | } | |
| 393 | ||
| 394 | function addBalloon(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void { | |
| 395 | const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT) | |
| 396 | const balloon = spawnBalloon(x, y, radius, ci) | |
| 397 | nuts.push(balloon) | |
5dba9120 2026-04-13 Pavel Safronov | 398 | nutBodyMap.set(balloon.body, balloon) |
f1262fd9 2026-04-12 Pavel Safronov | 399 | Composite.add(engine.world, balloon.body) |
| 400 | } | |
| 401 | ||
cfe80f49 2026-04-13 Pavel Safronov | 402 | function addStressBall(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void { |
| 403 | const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT) | |
| 404 | const sb = spawnStressBall(x, y, radius, ci) | |
| 405 | nuts.push(sb) | |
5dba9120 2026-04-13 Pavel Safronov | 406 | nutBodyMap.set(sb.body, sb) |
cfe80f49 2026-04-13 Pavel Safronov | 407 | Composite.add(engine.world, sb.body) |
| 408 | } | |
| 409 | ||
a2e3ea1a 2026-04-13 Pavel Safronov | 410 | function addCoin(x: number, y: number, radius: number = CONFIG.nutBaseRadius, materialIdx?: number): void { |
| 411 | const mi = materialIdx ?? Math.floor(Math.random() * COIN_MATERIAL_COUNT) | |
| 412 | const coin = spawnCoin(x, y, radius, mi) | |
| 413 | Body.setAngle(coin.body, Math.random() * Math.PI * 2) | |
| 414 | nuts.push(coin) | |
| 415 | nutBodyMap.set(coin.body, coin) | |
| 416 | Composite.add(engine.world, coin.body) | |
| 417 | } | |
| 418 | ||
a2b3b1f4 2026-04-12 Pavel Safronov | 419 | function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void { |
| 420 | const colorIndex = nuts.length | |
| 421 | const m = spawnMetalNut(x, y, nutRadius, colorIndex) | |
| 422 | Body.setAngle(m.body, Math.random() * Math.PI * 2) | |
| 423 | nuts.push(m) | |
5dba9120 2026-04-13 Pavel Safronov | 424 | nutBodyMap.set(m.body, m) |
a2b3b1f4 2026-04-12 Pavel Safronov | 425 | Composite.add(engine.world, m.body) |
| 426 | } | |
| 427 | ||
74d506c6 2026-04-12 Pavel Safronov | 428 | function addRock(x: number, y: number, radius: number = CONFIG.nutBaseRadius): void { |
| 429 | const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT) | |
| 430 | const rock = spawnRock(x, y, radius, colorIndex) | |
| 431 | Body.setAngle(rock.body, Math.random() * Math.PI * 2) | |
| 432 | nuts.push(rock) | |
5dba9120 2026-04-13 Pavel Safronov | 433 | nutBodyMap.set(rock.body, rock) |
74d506c6 2026-04-12 Pavel Safronov | 434 | Composite.add(engine.world, rock.body) |
| 435 | } | |
| 436 | ||
cfe80f49 2026-04-13 Pavel Safronov | 437 | function processPops(): void { |
| 438 | if (popQueue.length === 0) return | |
| 439 | const toPop = popQueue.splice(0) | |
| 440 | for (const nut of toPop) { | |
| 441 | poppingIds.delete(nut.body.id) | |
| 442 | const idx = nuts.indexOf(nut) | |
| 443 | if (idx === -1) continue | |
5dba9120 2026-04-13 Pavel Safronov | 444 | nutBodyMap.delete(nut.body) |
cfe80f49 2026-04-13 Pavel Safronov | 445 | Composite.remove(engine.world, nut.body) |
| 446 | nuts.splice(idx, 1) | |
| 447 | vibrate(70) // satisfying pop | |
aa6e5a99 2026-04-13 Pavel Safronov | 448 | playImpact('balloon_pop', 0.9, nut.radius) |
| 449 | world.onRemoved?.(nut.kind, nut.radius) | |
cfe80f49 2026-04-13 Pavel Safronov | 450 | } |
| 451 | } | |
| 452 | ||
74d506c6 2026-04-12 Pavel Safronov | 453 | function processSplits(): void { |
| 454 | if (splitQueue.length === 0) return | |
| 455 | const toSplit = splitQueue.splice(0) | |
| 456 | for (const nut of toSplit) { | |
| 457 | splittingIds.delete(nut.body.id) | |
| 458 | const idx = nuts.indexOf(nut) | |
| 459 | if (idx === -1) continue | |
98ba5504 2026-04-13 Pavel Safronov | 460 | |
| 461 | const pos = { x: nut.body.position.x, y: nut.body.position.y } | |
| 462 | const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y } | |
| 463 | ||
5dba9120 2026-04-13 Pavel Safronov | 464 | nutBodyMap.delete(nut.body) |
74d506c6 2026-04-12 Pavel Safronov | 465 | Composite.remove(engine.world, nut.body) |
| 466 | nuts.splice(idx, 1) | |
| 467 | ||
| 468 | if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue | |
| 469 | ||
98ba5504 2026-04-13 Pavel Safronov | 470 | // Spawn smaller rocks preserving total area (≈ mass at constant density) |
| 471 | // More pieces for larger rocks: 2 at min size, up to 5 at max size | |
| 472 | const sizeFraction = Math.min(1, (nut.radius - CONFIG.nutMinRadius) / (CONFIG.nutMaxRadius - CONFIG.nutMinRadius)) | |
| 473 | const count = Math.round(2 + sizeFraction * 3) // 2–5 fragments | |
| 474 | // r² × count = R² → r = R / √count | |
| 475 | const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95 | |
| 476 | ||
74d506c6 2026-04-12 Pavel Safronov | 477 | for (let i = 0; i < count; i++) { |
aa6e5a99 2026-04-13 Pavel Safronov | 478 | // Fragment too small — fully disintegrated |
| 479 | if (fragRadius < CONFIG.nutMinRadius * 0.5) { | |
| 480 | world.onRemoved?.(nut.kind, nut.radius) | |
| 481 | continue | |
| 482 | } | |
98ba5504 2026-04-13 Pavel Safronov | 483 | const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5 |
| 484 | const dist = nut.radius * 0.35 | |
| 485 | const fx = pos.x + Math.cos(spreadAngle) * dist | |
| 486 | const fy = pos.y + Math.sin(spreadAngle) * dist | |
| 487 | const frag = spawnRock(fx, fy, fragRadius, nut.colorIndex) | |
| 488 | Body.setVelocity(frag.body, { | |
| 489 | x: vel.x + Math.cos(spreadAngle) * (2 + Math.random() * 2), | |
| 490 | y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2), | |
74d506c6 2026-04-12 Pavel Safronov | 491 | }) |
98ba5504 2026-04-13 Pavel Safronov | 492 | nuts.push(frag) |
5dba9120 2026-04-13 Pavel Safronov | 493 | nutBodyMap.set(frag.body, frag) |
98ba5504 2026-04-13 Pavel Safronov | 494 | Composite.add(engine.world, frag.body) |
74d506c6 2026-04-12 Pavel Safronov | 495 | } |
| 496 | } | |
| 497 | } | |
| 498 | ||
f1262fd9 2026-04-12 Pavel Safronov | 499 | /** Remove the topmost body whose bounding circle contains (x, y). Returns true if one was removed. */ |
| 500 | function removeAt(x: number, y: number): boolean { | |
| 501 | for (let i = nuts.length - 1; i >= 0; i--) { | |
| 502 | const { body, radius } = nuts[i] | |
| 503 | const dx = body.position.x - x | |
| 504 | const dy = body.position.y - y | |
| 505 | if (dx * dx + dy * dy <= radius * radius) { | |
5dba9120 2026-04-13 Pavel Safronov | 506 | nutBodyMap.delete(body) |
f1262fd9 2026-04-12 Pavel Safronov | 507 | Composite.remove(engine.world, body) |
| 508 | nuts.splice(i, 1) | |
| 509 | return true | |
| 510 | } | |
| 511 | } | |
| 512 | return false | |
| 513 | } | |
| 514 | ||
| 515 | function clearNuts(): void { | |
44eb5832 2026-04-12 Pavel Safronov | 516 | for (const { body } of nuts) Composite.remove(engine.world, body) |
5dba9120 2026-04-13 Pavel Safronov | 517 | nutBodyMap.clear() |
44eb5832 2026-04-12 Pavel Safronov | 518 | nuts = [] |
| 519 | } | |
| 520 | ||
| 521 | function resize(newWidth: number, newHeight: number): void { | |
| 522 | for (const wall of walls) Composite.remove(engine.world, wall) | |
| 523 | walls = buildWalls(newWidth, newHeight) | |
| 524 | Composite.add(engine.world, walls) | |
| 525 | } | |
| 526 | ||
| 527 | function applyShakeForce(intensity: number): void { | |
| 528 | const scale = CONFIG.shakeForceScale * (intensity / CONFIG.shakeAccelThreshold) | |
| 529 | for (const { body } of nuts) { | |
| 530 | Body.applyForce(body, body.position, { | |
| 531 | x: (Math.random() - 0.5) * scale * body.mass, | |
| 532 | y: (Math.random() - 0.5) * scale * body.mass, | |
| 533 | }) | |
| 534 | } | |
| 535 | } | |
| 536 | ||
| 537 | function setGravity(gx: number, gy: number): void { | |
| 538 | engine.gravity.x = gx | |
| 539 | engine.gravity.y = gy === 0 ? 1e-4 : gy // avoid full zero-gravity | |
| 540 | } | |
| 541 | ||
| 542 | function step(deltaMs: number): void { | |
| 543 | Engine.update(engine, Math.min(deltaMs, 50)) | |
74d506c6 2026-04-12 Pavel Safronov | 544 | processSplits() |
cfe80f49 2026-04-13 Pavel Safronov | 545 | processPops() |
44eb5832 2026-04-12 Pavel Safronov | 546 | } |
| 547 | ||
| 548 | function destroy(): void { | |
| 549 | Composite.clear(engine.world, false) | |
| 550 | Engine.clear(engine) | |
| 551 | } | |
| 552 | ||
aa6e5a99 2026-04-13 Pavel Safronov | 553 | const world: PhysicsWorld = { |
f1262fd9 2026-04-12 Pavel Safronov | 554 | engine, |
| 555 | get nuts() { return nuts }, | |
aa6e5a99 2026-04-13 Pavel Safronov | 556 | onRemoved: null, |
f1262fd9 2026-04-12 Pavel Safronov | 557 | addNut, |
| 558 | addGem, | |
| 559 | addBalloon, | |
cfe80f49 2026-04-13 Pavel Safronov | 560 | addStressBall, |
a2e3ea1a 2026-04-13 Pavel Safronov | 561 | addCoin, |
a2b3b1f4 2026-04-12 Pavel Safronov | 562 | addMetalNut, |
74d506c6 2026-04-12 Pavel Safronov | 563 | addRock, |
f1262fd9 2026-04-12 Pavel Safronov | 564 | removeAt, |
| 565 | clearNuts, | |
| 566 | resize, | |
| 567 | applyShakeForce, | |
| 568 | setGravity, | |
| 569 | step, | |
| 570 | destroy, | |
| 571 | } | |
aa6e5a99 2026-04-13 Pavel Safronov | 572 | return world |
44eb5832 2026-04-12 Pavel Safronov | 573 | } |