fuzzyworld

nuts / files / src / physics.ts

physics.ts

25.6 KB · history · blame

1import Matter from 'matter-js'
2import { CONFIG } from './config'
3import { vibrate, vibrateWall } from './vibration'
4import { playImpact, type ImpactKind } from './sound'
5import { dbg } from './debug'
6import { ROCK_TEMPLATES } from './rockTemplates'
7
8const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
9
10// ── Item label set (single source of truth for "is this an item body?") ────
11export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] as const)
12
13// ── Types ──────────────────────────────────────────────────────────────────
14
15export 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
26export 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
48function 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
59function 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
82function 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
96const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant
97export const GEM_VARIANT_COUNT = GEM_SIDES.length
98export const BALLOON_COLOR_COUNT = 7
99export const STRESSBALL_COLOR_COUNT = 6
100export const COIN_MATERIAL_COUNT = 6
101export const COIN_ANIMAL_COUNT = 10
102
103// Per-material physics differ: density drives heft, restitution the bounce character
104const 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
113function 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
128const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon
129const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier
130
131function 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
144const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon
145
146function 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
162function 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
177const ROCK_COLORS_COUNT = 4
178const ROCK_SPLIT_SPEED = 6 // min px/tick to count as a hit
179const ROCK_HITS_TO_SPLIT = 10 // hits required to break
180
181function 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
200export 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}