nuts / files / src / physics.ts
physics.ts
| 1 | import Matter from 'matter-js' |
| 2 | import { CONFIG } from './config' |
| 3 | import { vibrate, vibrateWall } from './vibration' |
| 4 | import { playImpact, type ImpactKind } from './sound' |
| 5 | import { dbg } from './debug' |
| 6 | import { ROCK_TEMPLATES } from './rockTemplates' |
| 7 | |
| 8 | const { Engine, Bodies, Body, Composite, Events, Vector } = Matter |
| 9 | |
| 10 | // ── Item label set (single source of truth for "is this an item body?") ──── |
| 11 | export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] as const) |
| 12 | |
| 13 | // ── Types ────────────────────────────────────────────────────────────────── |
| 14 | |
| 15 | export interface NutBody { |
| 16 | body: Matter.Body |
| 17 | radius: number |
| 18 | colorIndex: number |
| 19 | kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin' |
| 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 |
| 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 |
| 24 | } |
| 25 | |
| 26 | export interface PhysicsWorld { |
| 27 | engine: Matter.Engine |
| 28 | nuts: NutBody[] |
| 29 | onRemoved: ((kind: NutBody['kind'], radius: number) => void) | null |
| 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 |
| 33 | addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void |
| 34 | addCoin: (x: number, y: number, radius?: number, materialIdx?: number) => void |
| 35 | addMetalNut: (x: number, y: number, nutRadius?: number) => void |
| 36 | addRock: (x: number, y: number, radius?: number) => void |
| 37 | removeAt: (x: number, y: number) => boolean |
| 38 | clearNuts: () => void |
| 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 |
| 50 | const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.9, frictionStatic: 0.3 } |
| 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 | |
| 59 | function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { |
| 60 | const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance |
| 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], { |
| 73 | density: 0.0006, // walnut ~0.6 g/cm³ — lighter than water |
| 74 | restitution: CONFIG.restitution, |
| 75 | friction: CONFIG.friction, |
| 76 | frictionAir: CONFIG.frictionAir, |
| 77 | label: 'nut', |
| 78 | }) |
| 79 | return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
| 80 | } |
| 81 | |
| 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 |
| 85 | const body = Bodies.polygon(x, y, 6, radius, { |
| 86 | density: 0.006, // ~6× default (0.001) — steel-like weight |
| 87 | restitution: 0.92, |
| 88 | friction: 0, |
| 89 | frictionStatic: 0, |
| 90 | frictionAir: 0.0008, |
| 91 | label: 'metalnut', |
| 92 | }) |
| 93 | return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
| 94 | } |
| 95 | |
| 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 |
| 99 | export const STRESSBALL_COLOR_COUNT = 6 |
| 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 |
| 112 | |
| 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, { |
| 118 | density: 0.0035, // crystal/gemstone ~3.5 g/cm³ |
| 119 | restitution: 0.95, |
| 120 | friction: 0, |
| 121 | frictionStatic: 0, |
| 122 | frictionAir: 0.0005, |
| 123 | label: 'gem', |
| 124 | }) |
| 125 | return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, state: 0, templateIdx: 0, toughness: 1 } |
| 126 | } |
| 127 | |
| 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 | |
| 131 | function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { |
| 132 | const variance = 0.5 + Math.random() * 1.0 // 0.5× to 1.5× base radius |
| 133 | const actualRadius = radius * variance |
| 134 | const body = Bodies.circle(x, y, actualRadius, { |
| 135 | density: 0.00005, // air-filled balloon — nearly weightless |
| 136 | restitution: 0.95, // very bouncy |
| 137 | friction: 0.005, |
| 138 | frictionAir: 0.006, |
| 139 | label: 'balloon', |
| 140 | }) |
| 141 | return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
| 142 | } |
| 143 | |
| 144 | const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon |
| 145 | |
| 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 | |
| 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 | }) |
| 172 | return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } |
| 173 | } |
| 174 | |
| 175 | // ── Factory ──────────────────────────────────────────────────────────────── |
| 176 | |
| 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 |
| 184 | const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length) |
| 185 | const template = ROCK_TEMPLATES[templateIdx] |
| 186 | |
| 187 | const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius)) |
| 188 | const body = Bodies.fromVertices(x, y, [verts], { |
| 189 | density: 0.0025, |
| 190 | restitution: 0.25, |
| 191 | friction: 0.65, |
| 192 | frictionAir: 0.001, |
| 193 | label: 'rock', |
| 194 | }) |
| 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 |
| 197 | return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, state: 0, templateIdx, toughness } |
| 198 | } |
| 199 | |
| 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[] = [] |
| 207 | const nutBodyMap = new Map<Matter.Body, NutBody>() |
| 208 | let splitQueue: NutBody[] = [] |
| 209 | const splittingIds = new Set<number>() |
| 210 | let popQueue: NutBody[] = [] |
| 211 | const poppingIds = new Set<number>() |
| 212 | |
| 213 | Composite.add(engine.world, walls) |
| 214 | |
| 215 | // ── Balloon buoyancy + squish decay ──────────────────────────────────── |
| 216 | // Balloons float against gravity (2.2× lift) and their squish decays each tick. |
| 217 | Events.on(engine, 'beforeUpdate', () => { |
| 218 | const scale = engine.gravity.scale * 2.2 |
| 219 | for (const nb of nuts) { |
| 220 | if (nb.kind === 'balloon') { |
| 221 | // Decay squish (hitCount) toward zero each tick |
| 222 | if (nb.state > 0) { |
| 223 | nb.state *= BALLOON_SQUISH_DECAY |
| 224 | if (nb.state < 0.005) nb.state = 0 |
| 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, |
| 230 | }) |
| 231 | } else if (nb.kind === 'stressball') { |
| 232 | if (nb.state > 0) { |
| 233 | nb.state *= STRESSBALL_SQUISH_DECAY |
| 234 | if (nb.state < 0.005) nb.state = 0 |
| 235 | } |
| 236 | } |
| 237 | } |
| 238 | }) |
| 239 | |
| 240 | // ── Collision haptics ────────────────────────────────────────────────── |
| 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 | |
| 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' |
| 257 | const aNut = ITEM_LABELS.has(pair.bodyA.label as never) |
| 258 | const bNut = ITEM_LABELS.has(pair.bodyB.label as never) |
| 259 | |
| 260 | const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut' |
| 261 | const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut' |
| 262 | const aRock = pair.bodyA.label === 'rock' |
| 263 | const bRock = pair.bodyB.label === 'rock' |
| 264 | const gemInvolved = aGem || bGem |
| 265 | |
| 266 | // Queue rock splits before vibration so we have speed info |
| 267 | const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) |
| 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) |
| 273 | if (!nb) return |
| 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 |
| 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 |
| 280 | splittingIds.add(rockBody.id) |
| 281 | splitQueue.push(nb) |
| 282 | } |
| 283 | if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB) |
| 284 | if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA) |
| 285 | } |
| 286 | |
| 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 |
| 295 | const nb = nutBodyMap.get(balloonBody) |
| 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) |
| 299 | nb.state = Math.min(nb.state + pressure, 1.5) |
| 300 | if (nb.state >= 1.0) { |
| 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) => { |
| 312 | const nb = nutBodyMap.get(sbBody) |
| 313 | if (!nb) return |
| 314 | const pressure = Math.pow(speed / 14, 1.2) |
| 315 | nb.state = Math.min(nb.state + pressure, 1.0) |
| 316 | } |
| 317 | if (aStress) applySquish(pair.bodyA) |
| 318 | if (bStress) applySquish(pair.bodyB) |
| 319 | } |
| 320 | |
| 321 | if (aNut && bNut) { |
| 322 | // Body-body collision |
| 323 | dbg('nut-nut speed:', speed.toFixed(2)) |
| 324 | if (speed > CONFIG.impactThreshold) { |
| 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 |
| 329 | if (aRock || bRock) { |
| 330 | const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) |
| 331 | vibrate(Math.round(12 + t * 28)) |
| 332 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
| 333 | } else if (aGem && bGem) { |
| 334 | const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1) |
| 335 | vibrate(Math.round(8 + t * 18)) |
| 336 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
| 337 | } else if (gemInvolved) { |
| 338 | const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) |
| 339 | vibrate(Math.round(10 + t * 22)) |
| 340 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
| 341 | } else { |
| 342 | const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) |
| 343 | vibrate(Math.round(10 + t * 30)) |
| 344 | playImpact(sndKind, 0.15 + t * 0.85, avgRadius) |
| 345 | } |
| 346 | } |
| 347 | } else if (aNut || bNut) { |
| 348 | // Body-wall collision |
| 349 | const nutBody = aWall ? pair.bodyB : pair.bodyA |
| 350 | if (!ITEM_LABELS.has(nutBody.label as never)) continue |
| 351 | const wallSpeed = nutBody.speed |
| 352 | const itemRadius = nutBodyMap.get(nutBody)?.radius ?? 60 |
| 353 | dbg('wall hit speed:', wallSpeed.toFixed(2)) |
| 354 | if (wallSpeed > CONFIG.impactThreshold) { |
| 355 | const sndKind = impactSoundKind(nutBody.label) |
| 356 | if (nutBody.label === 'rock') { |
| 357 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1) |
| 358 | vibrateWall(Math.round(15 + t * 30)) |
| 359 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
| 360 | } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { |
| 361 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1) |
| 362 | vibrateWall(Math.round(15 + t * 35)) |
| 363 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
| 364 | } else { |
| 365 | const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1) |
| 366 | vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) |
| 367 | playImpact(sndKind, 0.1 + t * 0.7, itemRadius) |
| 368 | } |
| 369 | } |
| 370 | } |
| 371 | } |
| 372 | }) |
| 373 | |
| 374 | // ── Public API ───────────────────────────────────────────────────────── |
| 375 | |
| 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) |
| 381 | nutBodyMap.set(nut.body, nut) |
| 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) |
| 390 | nutBodyMap.set(gem.body, gem) |
| 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) |
| 398 | nutBodyMap.set(balloon.body, balloon) |
| 399 | Composite.add(engine.world, balloon.body) |
| 400 | } |
| 401 | |
| 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) |
| 406 | nutBodyMap.set(sb.body, sb) |
| 407 | Composite.add(engine.world, sb.body) |
| 408 | } |
| 409 | |
| 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 | |
| 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) |
| 424 | nutBodyMap.set(m.body, m) |
| 425 | Composite.add(engine.world, m.body) |
| 426 | } |
| 427 | |
| 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) |
| 433 | nutBodyMap.set(rock.body, rock) |
| 434 | Composite.add(engine.world, rock.body) |
| 435 | } |
| 436 | |
| 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 |
| 444 | nutBodyMap.delete(nut.body) |
| 445 | Composite.remove(engine.world, nut.body) |
| 446 | nuts.splice(idx, 1) |
| 447 | vibrate(70) // satisfying pop |
| 448 | playImpact('balloon_pop', 0.9, nut.radius) |
| 449 | world.onRemoved?.(nut.kind, nut.radius) |
| 450 | } |
| 451 | } |
| 452 | |
| 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 |
| 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 | |
| 464 | nutBodyMap.delete(nut.body) |
| 465 | Composite.remove(engine.world, nut.body) |
| 466 | nuts.splice(idx, 1) |
| 467 | |
| 468 | if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue |
| 469 | |
| 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 | |
| 477 | for (let i = 0; i < count; i++) { |
| 478 | // Fragment too small — fully disintegrated |
| 479 | if (fragRadius < CONFIG.nutMinRadius * 0.5) { |
| 480 | world.onRemoved?.(nut.kind, nut.radius) |
| 481 | continue |
| 482 | } |
| 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), |
| 491 | }) |
| 492 | nuts.push(frag) |
| 493 | nutBodyMap.set(frag.body, frag) |
| 494 | Composite.add(engine.world, frag.body) |
| 495 | } |
| 496 | } |
| 497 | } |
| 498 | |
| 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) { |
| 506 | nutBodyMap.delete(body) |
| 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 { |
| 516 | for (const { body } of nuts) Composite.remove(engine.world, body) |
| 517 | nutBodyMap.clear() |
| 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)) |
| 544 | processSplits() |
| 545 | processPops() |
| 546 | } |
| 547 | |
| 548 | function destroy(): void { |
| 549 | Composite.clear(engine.world, false) |
| 550 | Engine.clear(engine) |
| 551 | } |
| 552 | |
| 553 | const world: PhysicsWorld = { |
| 554 | engine, |
| 555 | get nuts() { return nuts }, |
| 556 | onRemoved: null, |
| 557 | addNut, |
| 558 | addGem, |
| 559 | addBalloon, |
| 560 | addStressBall, |
| 561 | addCoin, |
| 562 | addMetalNut, |
| 563 | addRock, |
| 564 | removeAt, |
| 565 | clearNuts, |
| 566 | resize, |
| 567 | applyShakeForce, |
| 568 | setGravity, |
| 569 | step, |
| 570 | destroy, |
| 571 | } |
| 572 | return world |
| 573 | } |