fuzzyworld

nuts / goo / files / src / physics.ts

Branch goo · default branch · all branches

physics.ts

28.5 KB · history · blame

1import Matter from 'matter-js'
2import { CONFIG } from './config'
3import { vibrate, vibrateWall } from './vibration'
4import { dbg } from './debug'
5import { ROCK_TEMPLATES } from './rockTemplates'
6
7const { Engine, Bodies, Body, Composite, Events, Vector, Constraint } = Matter
8
9// ── Types ──────────────────────────────────────────────────────────────────
10
11export interface NutBody {
12 body: Matter.Body
13 radius: number
14 colorIndex: number
15 kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
16 gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex
17 hitCount: number // rocks: accumulated damage
18 templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks
19 toughness: number // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks
20 stickyAmount: number // 0 = clean, 1 = fully coated in goo
21}
22
23export interface GooConstraint {
24 bodyA: Matter.Body
25 bodyB: Matter.Body
26 nutA: NutBody
27 nutB: NutBody
28 constraint: Matter.Constraint
29 strength: number // 1.0 = fresh bond → 0 = broken
30}
31
32export interface PhysicsWorld {
33 engine: Matter.Engine
34 nuts: NutBody[]
35 addNut: (x: number, y: number, nutRadius?: number) => void
36 addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void
37 addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void
38 addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void
39 addMetalNut: (x: number, y: number, nutRadius?: number) => void
40 addRock: (x: number, y: number, radius?: number) => void
41 removeAt: (x: number, y: number) => boolean
42 clearNuts: () => void
43 resize: (width: number, height: number) => void
44 applyShakeForce: (intensity: number) => void
45 setGravity: (gx: number, gy: number) => void
46 get gooConstraints(): GooConstraint[]
47 step: (deltaMs: number) => void
48 destroy: () => void
49}
50
51// ── Helpers ────────────────────────────────────────────────────────────────
52
53function buildWalls(width: number, height: number): Matter.Body[] {
54 const t = CONFIG.wallThickness
55 const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.9, frictionStatic: 0.3 }
56 return [
57 Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts), // top
58 Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts), // bottom
59 Bodies.rectangle(-t / 2, height / 2, t, height + t * 2, opts), // left
60 Bodies.rectangle(width + t / 2, height / 2, t, height + t * 2, opts), // right
61 ]
62}
63
64function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
65 const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
66 const radius = nutRadius * variance
67 const ry = radius / CONFIG.nutAspectRatio
68
69 // Convex ellipse polygon approximation (20 vertices)
70 const N = 20
71 const verts: Matter.Vector[] = []
72 for (let i = 0; i < N; i++) {
73 const a = (i / N) * Math.PI * 2
74 verts.push(Vector.create(Math.cos(a) * radius, Math.sin(a) * ry))
75 }
76
77 const body = Bodies.fromVertices(x, y, [verts], {
78 density: 0.0006, // walnut ~0.6 g/cm³ — lighter than water
79 restitution: CONFIG.restitution,
80 friction: CONFIG.friction,
81 frictionAir: CONFIG.frictionAir,
82 label: 'nut',
83 })
84 return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
85}
86
87function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
88 const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
89 const radius = nutRadius * variance
90 const body = Bodies.polygon(x, y, 6, radius, {
91 density: 0.006, // ~6× default (0.001) — steel-like weight
92 restitution: 0.92,
93 friction: 0,
94 frictionStatic: 0,
95 frictionAir: 0.0008,
96 label: 'metalnut',
97 })
98 return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
99}
100
101const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant
102export const GEM_VARIANT_COUNT = GEM_SIDES.length
103export const BALLOON_COLOR_COUNT = 7
104export const STRESSBALL_COLOR_COUNT = 6
105
106const STRESSBALL_COLORS = [
107 '#e05050', // red
108 '#e07830', // orange
109 '#d4c020', // yellow
110 '#50b050', // green
111 '#4090d0', // blue
112 '#9060c0', // purple
113]
114
115function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody {
116 const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
117 const actualRadius = radius * variance
118 const sides = GEM_SIDES[gemVariant % GEM_SIDES.length]
119 const body = Bodies.polygon(x, y, sides, actualRadius, {
120 density: 0.0035, // crystal/gemstone ~3.5 g/cm³
121 restitution: 0.95,
122 friction: 0,
123 frictionStatic: 0,
124 frictionAir: 0.0005,
125 label: 'gem',
126 })
127 return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
128}
129
130const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon
131const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier
132
133function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
134 const variance = 0.5 + Math.random() * 1.0 // 0.5× to 1.5× base radius
135 const actualRadius = radius * variance
136 const body = Bodies.circle(x, y, actualRadius, {
137 density: 0.00005, // air-filled balloon — nearly weightless
138 restitution: 0.95, // very bouncy
139 friction: 0.005,
140 frictionAir: 0.006,
141 label: 'balloon',
142 })
143 return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
144}
145
146const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon
147const STRESSBALL_EXPLODE_PRESSURE = 1.0
148const STRESSBALL_EXPLODE_RADIUS_MULT = 3.2 // how far the goo splash reaches
149
150const GOO_BOND_MIN_STICKY = 0.25 // both bodies need at least this to bond
151const GOO_BOND_MAX = 20 // max simultaneous constraints (physics budget)
152const GOO_BOND_BREAK_SPEED = 7 // px/tick threshold for bond damage
153const GOO_STRENGTH_DECAY = 0.0004 // natural bond decay per physics tick
154const STICKY_DECAY = 0.9994 // stickyAmount decay per tick (unbonded)
155
156function spawnStressBall(x: number, y: number, radius: number, colorIndex: number): NutBody {
157 const variance = 0.7 + Math.random() * 0.6 // 0.7× to 1.3× base radius
158 const actualRadius = radius * variance
159 const body = Bodies.circle(x, y, actualRadius, {
160 density: 0.0006, // denser than balloon — has some heft
161 restitution: 0.55, // bouncy but not as floaty as balloon
162 friction: 0.4, // grippy rubber
163 frictionAir: 0.015,
164 label: 'stressball',
165 })
166 return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
167}
168
169// ── Factory ────────────────────────────────────────────────────────────────
170
171const ROCK_COLORS_COUNT = 4
172const ROCK_SPLIT_SPEED = 6 // min px/tick to count as a hit
173const ROCK_HITS_TO_SPLIT = 10 // hits required to break
174
175function spawnRock(x: number, y: number, targetRadius: number, colorIndex: number): NutBody {
176 const variance = 0.85 + Math.random() * 0.3
177 const radius = targetRadius * variance
178 const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length)
179 const template = ROCK_TEMPLATES[templateIdx]
180
181 const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius))
182 const body = Bodies.fromVertices(x, y, [verts], {
183 density: 0.0025,
184 restitution: 0.25,
185 friction: 0.65,
186 frictionAir: 0.001,
187 label: 'rock',
188 })
189 // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard)
190 const toughness = Math.pow(2, (Math.random() - 0.5) * 2) // 2^[-1, 1] = 0.5 … 2.0
191 return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness, stickyAmount: 0 }
192}
193
194export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
195 const engine = Engine.create()
196 engine.gravity.x = 0
197 engine.gravity.y = CONFIG.gravityScale
198
199 let walls = buildWalls(width, height)
200 let nuts: NutBody[] = []
201 let currentWidth = width
202 let currentHeight = height
203 let splitQueue: NutBody[] = []
204 const splittingIds = new Set<number>()
205 let popQueue: NutBody[] = []
206 const poppingIds = new Set<number>()
207 let gooConstraints: GooConstraint[] = []
208 let explosionQueue: NutBody[] = []
209 const explodingIds = new Set<number>()
210
211 Composite.add(engine.world, walls)
212
213 // ── Balloon buoyancy + squish decay ────────────────────────────────────
214 // Balloons float against gravity (2.2× lift) and their squish decays each tick.
215 Events.on(engine, 'beforeUpdate', () => {
216 const scale = engine.gravity.scale * 2.2
217 for (const nb of nuts) {
218 if (nb.kind === 'balloon') {
219 // Decay squish (hitCount) toward zero each tick
220 if (nb.hitCount > 0) {
221 nb.hitCount *= BALLOON_SQUISH_DECAY
222 if (nb.hitCount < 0.005) nb.hitCount = 0
223 }
224 // Buoyancy
225 Body.applyForce(nb.body, nb.body.position, {
226 x: -nb.body.mass * engine.gravity.x * scale,
227 y: -nb.body.mass * engine.gravity.y * scale,
228 })
229 } else if (nb.kind === 'stressball') {
230 if (nb.hitCount > 0) {
231 nb.hitCount *= STRESSBALL_SQUISH_DECAY
232 if (nb.hitCount < 0.005) nb.hitCount = 0
233 }
234 }
235 }
236
237 // ── Goo bond decay + sticky body decay ──────────────────────────
238 // Age each bond; broken ones are removed and partners lose stickiness.
239 const brokenBonds: GooConstraint[] = []
240 for (const gc of gooConstraints) {
241 gc.strength -= GOO_STRENGTH_DECAY
242 if (gc.strength <= 0) brokenBonds.push(gc)
243 }
244 for (const gc of brokenBonds) {
245 Composite.remove(engine.world, gc.constraint)
246 gooConstraints = gooConstraints.filter(x => x !== gc)
247 // If partner now has no bonds left, halve their stickiness
248 if (!gooConstraints.some(x => x.bodyA === gc.bodyA || x.bodyB === gc.bodyA))
249 gc.nutA.stickyAmount *= 0.45
250 if (!gooConstraints.some(x => x.bodyA === gc.bodyB || x.bodyB === gc.bodyB))
251 gc.nutB.stickyAmount *= 0.45
252 }
253 // Unbonded sticky bodies lose their goo over time
254 for (const nb of nuts) {
255 if (nb.stickyAmount > 0) {
256 const bonded = gooConstraints.some(gc => gc.bodyA === nb.body || gc.bodyB === nb.body)
257 if (!bonded) {
258 nb.stickyAmount *= STICKY_DECAY
259 if (nb.stickyAmount < 0.01) nb.stickyAmount = 0
260 }
261 }
262 }
263 })
264
265 // ── Collision haptics ──────────────────────────────────────────────────
266 Events.on(engine, 'collisionStart', (event: Matter.IEventCollision<Matter.Engine>) => {
267 for (const pair of event.pairs) {
268 const aWall = pair.bodyA.label === 'wall'
269 const bWall = pair.bodyB.label === 'wall'
270 const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock' || pair.bodyA.label === 'stressball'
271 const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' || pair.bodyB.label === 'stressball'
272
273 const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut'
274 const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut'
275 const aRock = pair.bodyA.label === 'rock'
276 const bRock = pair.bodyB.label === 'rock'
277 const gemInvolved = aGem || bGem
278
279 // Queue rock splits before vibration so we have speed info
280 const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed)
281 if (speed > ROCK_SPLIT_SPEED) {
282 const tryQueueSplit = (rockBody: Matter.Body, otherBody: Matter.Body) => {
283 if (rockBody.label !== 'rock') return
284 if (splittingIds.has(rockBody.id)) return
285 const nb = nuts.find(n => n.body === rockBody)
286 if (!nb) return
287 // Damage = excess kinetic energy of impactor relative to rock mass.
288 // Normalised so 10 hits at ROCK_SPLIT_SPEED*1.5 from equal mass ≈ 10.
289 const excess = speed - ROCK_SPLIT_SPEED
290 const massRatio = otherBody.mass / rockBody.mass
291 nb.hitCount += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness)
292 if (nb.hitCount < ROCK_HITS_TO_SPLIT) return
293 splittingIds.add(rockBody.id)
294 splitQueue.push(nb)
295 }
296 if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB)
297 if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA)
298 }
299
300 // ── Balloon squish / pop ───────────────────────────────────
301 const aBalloon = pair.bodyA.label === 'balloon'
302 const bBalloon = pair.bodyB.label === 'balloon'
303 const aStress = pair.bodyA.label === 'stressball'
304 const bStress = pair.bodyB.label === 'stressball'
305 if (aBalloon || bBalloon) {
306 const applyPressure = (balloonBody: Matter.Body) => {
307 if (poppingIds.has(balloonBody.id)) return
308 const nb = nuts.find(n => n.body === balloonBody)
309 if (!nb) return
310 // Pressure scales as speed^1.5 so gentle bounces squish, hard hits pop
311 const pressure = Math.pow(speed / BALLOON_POP_SPEED, 1.5)
312 nb.hitCount = Math.min(nb.hitCount + pressure, 1.5)
313 if (nb.hitCount >= 1.0) {
314 poppingIds.add(balloonBody.id)
315 popQueue.push(nb)
316 }
317 }
318 if (aBalloon) applyPressure(pair.bodyA)
319 if (bBalloon) applyPressure(pair.bodyB)
320 }
321
322 // ── Stress ball squish / explode ────────────────────────────
323 if (aStress || bStress) {
324 const applySquish = (sbBody: Matter.Body) => {
325 if (explodingIds.has(sbBody.id)) return
326 const nb = nuts.find(n => n.body === sbBody)
327 if (!nb) return
328 const pressure = Math.pow(speed / 14, 1.2)
329 nb.hitCount += pressure
330 if (nb.hitCount >= STRESSBALL_EXPLODE_PRESSURE) {
331 explodingIds.add(sbBody.id)
332 explosionQueue.push(nb)
333 }
334 }
335 if (aStress) applySquish(pair.bodyA)
336 if (bStress) applySquish(pair.bodyB)
337 }
338
339 // ── Goo bond formation (sticky bodies touching each other) ───────
340 if (aNut && bNut && gooConstraints.length < GOO_BOND_MAX) {
341 const nbA = nuts.find(n => n.body === pair.bodyA)
342 const nbB = nuts.find(n => n.body === pair.bodyB)
343 if (nbA && nbB &&
344 nbA.stickyAmount >= GOO_BOND_MIN_STICKY &&
345 nbB.stickyAmount >= GOO_BOND_MIN_STICKY &&
346 !gooConstraints.some(gc =>
347 (gc.bodyA === pair.bodyA && gc.bodyB === pair.bodyB) ||
348 (gc.bodyA === pair.bodyB && gc.bodyB === pair.bodyA))) {
349 const c = Constraint.create({
350 bodyA: pair.bodyA,
351 bodyB: pair.bodyB,
352 stiffness: 0.85,
353 damping: 0.18,
354 label: 'goo',
355 })
356 Composite.add(engine.world, c)
357 gooConstraints.push({ bodyA: pair.bodyA, bodyB: pair.bodyB, nutA: nbA, nutB: nbB, constraint: c, strength: 1.0 })
358 }
359 }
360
361 // ── Goo bond damage (hard hits weaken bonds) ────────────────
362 if (speed > GOO_BOND_BREAK_SPEED) {
363 const damageBodyBonds = (b: Matter.Body) => {
364 const excess = speed - GOO_BOND_BREAK_SPEED
365 const dmg = (excess * excess) * 0.014
366 for (const gc of gooConstraints) {
367 if (gc.bodyA === b || gc.bodyB === b) gc.strength -= dmg
368 }
369 }
370 if (aNut) damageBodyBonds(pair.bodyA)
371 if (bNut) damageBodyBonds(pair.bodyB)
372 }
373
374 if (aNut && bNut) {
375 // Body-body collision
376 dbg('nut-nut speed:', speed.toFixed(2))
377 if (speed > CONFIG.impactThreshold) {
378 if (aRock || bRock) {
379 // rock collision: earthy thud
380 const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
381 vibrate(Math.round(12 + t * 28))
382 } else if (aGem && bGem) {
383 const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
384 vibrate(Math.round(8 + t * 18))
385 } else if (gemInvolved) {
386 const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
387 vibrate(Math.round(10 + t * 22))
388 } else {
389 const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
390 vibrate(Math.round(10 + t * 30))
391 }
392 }
393 } else if (aNut || bNut) {
394 // Body-wall collision
395 const nutBody = aWall ? pair.bodyB : pair.bodyA
396 if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock' && nutBody.label !== 'stressball') continue
397 const wallSpeed = nutBody.speed
398 dbg('wall hit speed:', wallSpeed.toFixed(2))
399 if (wallSpeed > CONFIG.impactThreshold) {
400 if (nutBody.label === 'rock') {
401 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1)
402 vibrateWall(Math.round(15 + t * 30))
403 } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
404 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1)
405 vibrateWall(Math.round(15 + t * 35))
406 } else {
407 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1)
408 vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
409 }
410 }
411 }
412 }
413 })
414
415 // ── Public API ─────────────────────────────────────────────────────────
416
417 function addNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
418 const colorIndex = nuts.length
419 const nut = spawnNut(x, y, nutRadius, colorIndex)
420 Body.setAngle(nut.body, Math.random() * Math.PI * 2)
421 nuts.push(nut)
422 Composite.add(engine.world, nut.body)
423 }
424
425 function addGem(x: number, y: number, radius: number = CONFIG.nutBaseRadius, gemVariant?: number): void {
426 const variant = gemVariant ?? Math.floor(Math.random() * GEM_VARIANT_COUNT)
427 const gem = spawnGem(x, y, radius, variant)
428 Body.setAngle(gem.body, Math.random() * Math.PI * 2)
429 nuts.push(gem)
430 Composite.add(engine.world, gem.body)
431 }
432
433 function addBalloon(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void {
434 const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT)
435 const balloon = spawnBalloon(x, y, radius, ci)
436 nuts.push(balloon)
437 Composite.add(engine.world, balloon.body)
438 }
439
440 function addStressBall(x: number, y: number, radius: number = CONFIG.nutBaseRadius, colorIndex?: number): void {
441 const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT)
442 const sb = spawnStressBall(x, y, radius, ci)
443 nuts.push(sb)
444 Composite.add(engine.world, sb.body)
445 }
446
447 function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
448 const colorIndex = nuts.length
449 const m = spawnMetalNut(x, y, nutRadius, colorIndex)
450 Body.setAngle(m.body, Math.random() * Math.PI * 2)
451 nuts.push(m)
452 Composite.add(engine.world, m.body)
453 }
454
455 function addRock(x: number, y: number, radius: number = CONFIG.nutBaseRadius): void {
456 const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT)
457 const rock = spawnRock(x, y, radius, colorIndex)
458 Body.setAngle(rock.body, Math.random() * Math.PI * 2)
459 nuts.push(rock)
460 Composite.add(engine.world, rock.body)
461 }
462
463 function removeBodyBonds(body: Matter.Body): void {
464 const toRemove = gooConstraints.filter(gc => gc.bodyA === body || gc.bodyB === body)
465 for (const gc of toRemove) Composite.remove(engine.world, gc.constraint)
466 gooConstraints = gooConstraints.filter(gc => gc.bodyA !== body && gc.bodyB !== body)
467 }
468
469 function processExplosions(): void {
470 if (explosionQueue.length === 0) return
471 const toExplode = explosionQueue.splice(0)
472 for (const sb of toExplode) {
473 explodingIds.delete(sb.body.id)
474 const idx = nuts.indexOf(sb)
475 if (idx === -1) continue
476 const pos = { x: sb.body.position.x, y: sb.body.position.y }
477 const splashR = sb.radius * STRESSBALL_EXPLODE_RADIUS_MULT
478 removeBodyBonds(sb.body)
479 Composite.remove(engine.world, sb.body)
480 nuts.splice(idx, 1)
481 // Coat nearby bodies in goo + small scatter impulse
482 for (const nb of nuts) {
483 const dx = nb.body.position.x - pos.x
484 const dy = nb.body.position.y - pos.y
485 const dist = Math.hypot(dx, dy)
486 if (dist < splashR + nb.radius) {
487 const intensity = Math.max(0, 1 - (dist - nb.radius) / splashR)
488 nb.stickyAmount = Math.min(1, nb.stickyAmount + intensity)
489 if (dist > 1) {
490 const force = nb.body.mass * 0.004 * intensity
491 Body.applyForce(nb.body, nb.body.position, { x: (dx / dist) * force, y: (dy / dist) * force })
492 }
493 }
494 }
495 vibrate(65)
496 }
497 }
498
499 function processPops(): void {
500 if (popQueue.length === 0) return
501 const toPop = popQueue.splice(0)
502 for (const nut of toPop) {
503 poppingIds.delete(nut.body.id)
504 const idx = nuts.indexOf(nut)
505 if (idx === -1) continue
506 Composite.remove(engine.world, nut.body)
507 nuts.splice(idx, 1)
508 vibrate(70) // satisfying pop
509 }
510 }
511
512 function processSplits(): void {
513 if (splitQueue.length === 0) return
514 const toSplit = splitQueue.splice(0)
515 for (const nut of toSplit) {
516 splittingIds.delete(nut.body.id)
517 const idx = nuts.indexOf(nut)
518 if (idx === -1) continue
519
520 const pos = { x: nut.body.position.x, y: nut.body.position.y }
521 const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y }
522
523 Composite.remove(engine.world, nut.body)
524 nuts.splice(idx, 1)
525
526 removeBodyBonds(nut.body)
527
528 if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue
529
530 // Spawn smaller rocks preserving total area (≈ mass at constant density)
531 // More pieces for larger rocks: 2 at min size, up to 5 at max size
532 const sizeFraction = Math.min(1, (nut.radius - CONFIG.nutMinRadius) / (CONFIG.nutMaxRadius - CONFIG.nutMinRadius))
533 const count = Math.round(2 + sizeFraction * 3) // 2–5 fragments
534 // r² × count = R² → r = R / √count
535 const fragRadius = (nut.radius / Math.sqrt(count)) * 0.95
536
537 for (let i = 0; i < count; i++) {
538 if (fragRadius < CONFIG.nutMinRadius * 0.5) continue
539 const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.5
540 const dist = nut.radius * 0.35
541 const fx = pos.x + Math.cos(spreadAngle) * dist
542 const fy = pos.y + Math.sin(spreadAngle) * dist
543 const frag = spawnRock(fx, fy, fragRadius, nut.colorIndex)
544 Body.setVelocity(frag.body, {
545 x: vel.x + Math.cos(spreadAngle) * (2 + Math.random() * 2),
546 y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2),
547 })
548 nuts.push(frag)
549 Composite.add(engine.world, frag.body)
550 }
551 }
552 }
553
554 /** Remove the topmost body whose bounding circle contains (x, y). Returns true if one was removed. */
555 function removeAt(x: number, y: number): boolean {
556 for (let i = nuts.length - 1; i >= 0; i--) {
557 const { body, radius } = nuts[i]
558 const dx = body.position.x - x
559 const dy = body.position.y - y
560 if (dx * dx + dy * dy <= radius * radius) {
561 removeBodyBonds(body)
562 Composite.remove(engine.world, body)
563 nuts.splice(i, 1)
564 return true
565 }
566 }
567 return false
568 }
569
570 function clearNuts(): void {
571 for (const { body } of nuts) Composite.remove(engine.world, body)
572 for (const gc of gooConstraints) Composite.remove(engine.world, gc.constraint)
573 gooConstraints = []
574 explosionQueue = []
575 explodingIds.clear()
576 nuts = []
577 }
578
579 function resize(newWidth: number, newHeight: number): void {
580 currentWidth = newWidth
581 currentHeight = newHeight
582 for (const wall of walls) Composite.remove(engine.world, wall)
583 walls = buildWalls(newWidth, newHeight)
584 Composite.add(engine.world, walls)
585 }
586
587 function applyShakeForce(intensity: number): void {
588 const scale = CONFIG.shakeForceScale * (intensity / CONFIG.shakeAccelThreshold)
589 for (const { body } of nuts) {
590 Body.applyForce(body, body.position, {
591 x: (Math.random() - 0.5) * scale * body.mass,
592 y: (Math.random() - 0.5) * scale * body.mass,
593 })
594 }
595 }
596
597 function setGravity(gx: number, gy: number): void {
598 engine.gravity.x = gx
599 engine.gravity.y = gy === 0 ? 1e-4 : gy // avoid full zero-gravity
600 }
601
602 function step(deltaMs: number): void {
603 Engine.update(engine, Math.min(deltaMs, 50))
604 processExplosions()
605 processSplits()
606 processPops()
607 }
608
609 function destroy(): void {
610 Composite.clear(engine.world, false)
611 Engine.clear(engine)
612 }
613
614 return {
615 engine,
616 get nuts() { return nuts },
617 get gooConstraints() { return gooConstraints },
618 addNut,
619 addGem,
620 addBalloon,
621 addStressBall,
622 addMetalNut,
623 addRock,
624 removeAt,
625 clearNuts,
626 resize,
627 applyShakeForce,
628 setGravity,
629 step,
630 destroy,
631 }
632}