fuzzyworld

nuts / lava / files / src / physics.ts

Branch lava · default branch · all branches

physics.ts

Blame: the commit that last changed each line · file · history

44eb5832 2026-04-12 Pavel Safronov1import Matter from 'matter-js'
2import { CONFIG } from './config'
98ba5504 2026-04-13 Pavel Safronov3import { vibrate, vibrateWall } from './vibration'
aa6e5a99 2026-04-13 Pavel Safronov4import { playImpact, type ImpactKind } from './sound'
f1262fd9 2026-04-12 Pavel Safronov5import { dbg } from './debug'
98ba5504 2026-04-13 Pavel Safronov6import { ROCK_TEMPLATES } from './rockTemplates'
44eb5832 2026-04-12 Pavel Safronov7
f1262fd9 2026-04-12 Pavel Safronov8const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
44eb5832 2026-04-12 Pavel Safronov9
5dba9120 2026-04-13 Pavel Safronov10// ── Item label set (single source of truth for "is this an item body?") ────
a2e3ea1a 2026-04-13 Pavel Safronov11export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] as const)
5dba9120 2026-04-13 Pavel Safronov12
44eb5832 2026-04-12 Pavel Safronov13// ── Types ──────────────────────────────────────────────────────────────────
14
15export interface NutBody {
16 body: Matter.Body
17 radius: number
18 colorIndex: number
a2e3ea1a 2026-04-13 Pavel Safronov19 kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
5dba9120 2026-04-13 Pavel Safronov20 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 Safronov22 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 Safronov24}
25
26export interface PhysicsWorld {
27 engine: Matter.Engine
28 nuts: NutBody[]
aa6e5a99 2026-04-13 Pavel Safronov29 onRemoved: ((kind: NutBody['kind'], radius: number) => void) | null
f1262fd9 2026-04-12 Pavel Safronov30 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 Safronov33 addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void
a2e3ea1a 2026-04-13 Pavel Safronov34 addCoin: (x: number, y: number, radius?: number, materialIdx?: number) => void
a2b3b1f4 2026-04-12 Pavel Safronov35 addMetalNut: (x: number, y: number, nutRadius?: number) => void
74d506c6 2026-04-12 Pavel Safronov36 addRock: (x: number, y: number, radius?: number) => void
f1262fd9 2026-04-12 Pavel Safronov37 removeAt: (x: number, y: number) => boolean
38 clearNuts: () => void
44eb5832 2026-04-12 Pavel Safronov39 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
a2b3b1f4 2026-04-12 Pavel Safronov50 const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.9, frictionStatic: 0.3 }
44eb5832 2026-04-12 Pavel Safronov51 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 Safronov59function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
44eb5832 2026-04-12 Pavel Safronov60 const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
f1262fd9 2026-04-12 Pavel Safronov61 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 Safronov73 density: 0.0006, // walnut ~0.6 g/cm³ — lighter than water
44eb5832 2026-04-12 Pavel Safronov74 restitution: CONFIG.restitution,
75 friction: CONFIG.friction,
76 frictionAir: CONFIG.frictionAir,
77 label: 'nut',
78 })
5dba9120 2026-04-13 Pavel Safronov79 return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
f1262fd9 2026-04-12 Pavel Safronov80}
81
a2b3b1f4 2026-04-12 Pavel Safronov82function 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 Safronov85 const body = Bodies.polygon(x, y, 6, radius, {
86 density: 0.006, // ~6× default (0.001) — steel-like weight
a2b3b1f4 2026-04-12 Pavel Safronov87 restitution: 0.92,
88 friction: 0,
89 frictionStatic: 0,
90 frictionAir: 0.0008,
91 label: 'metalnut',
92 })
5dba9120 2026-04-13 Pavel Safronov93 return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
a2b3b1f4 2026-04-12 Pavel Safronov94}
95
f1262fd9 2026-04-12 Pavel Safronov96const 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
cfe80f49 2026-04-13 Pavel Safronov99export const STRESSBALL_COLOR_COUNT = 6
a2e3ea1a 2026-04-13 Pavel Safronov100export 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
cfe80f49 2026-04-13 Pavel Safronov112
f1262fd9 2026-04-12 Pavel Safronov113function 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 Safronov118 density: 0.0035, // crystal/gemstone ~3.5 g/cm³
a2b3b1f4 2026-04-12 Pavel Safronov119 restitution: 0.95,
120 friction: 0,
121 frictionStatic: 0,
122 frictionAir: 0.0005,
f1262fd9 2026-04-12 Pavel Safronov123 label: 'gem',
124 })
5dba9120 2026-04-13 Pavel Safronov125 return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, state: 0, templateIdx: 0, toughness: 1 }
f1262fd9 2026-04-12 Pavel Safronov126}
127
cfe80f49 2026-04-13 Pavel Safronov128const 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
f1262fd9 2026-04-12 Pavel Safronov131function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody {
fd4adeec 2026-04-12 Pavel Safronov132 const variance = 0.5 + Math.random() * 1.0 // 0.5× to 1.5× base radius
f1262fd9 2026-04-12 Pavel Safronov133 const actualRadius = radius * variance
134 const body = Bodies.circle(x, y, actualRadius, {
fd4adeec 2026-04-12 Pavel Safronov135 density: 0.00005, // air-filled balloon — nearly weightless
cfe80f49 2026-04-13 Pavel Safronov136 restitution: 0.95, // very bouncy
137 friction: 0.005,
138 frictionAir: 0.006,
f1262fd9 2026-04-12 Pavel Safronov139 label: 'balloon',
140 })
5dba9120 2026-04-13 Pavel Safronov141 return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
44eb5832 2026-04-12 Pavel Safronov142}
143
cfe80f49 2026-04-13 Pavel Safronov144const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon
145
a2e3ea1a 2026-04-13 Pavel Safronov146function 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 Safronov162function 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 Safronov172 return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 }
cfe80f49 2026-04-13 Pavel Safronov173}
174
44eb5832 2026-04-12 Pavel Safronov175// ── Factory ────────────────────────────────────────────────────────────────
176
74d506c6 2026-04-12 Pavel Safronov177const 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
98ba5504 2026-04-13 Pavel Safronov184 const templateIdx = Math.floor(Math.random() * ROCK_TEMPLATES.length)
185 const template = ROCK_TEMPLATES[templateIdx]
74d506c6 2026-04-12 Pavel Safronov186
98ba5504 2026-04-13 Pavel Safronov187 const verts = template.normVerts.map(v => Vector.create(v.x * radius, v.y * radius))
74d506c6 2026-04-12 Pavel Safronov188 const body = Bodies.fromVertices(x, y, [verts], {
98ba5504 2026-04-13 Pavel Safronov189 density: 0.0025,
190 restitution: 0.25,
74d506c6 2026-04-12 Pavel Safronov191 friction: 0.65,
192 frictionAir: 0.001,
193 label: 'rock',
194 })
98ba5504 2026-04-13 Pavel Safronov195 // 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 Safronov197 return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, state: 0, templateIdx, toughness }
74d506c6 2026-04-12 Pavel Safronov198}
199
44eb5832 2026-04-12 Pavel Safronov200export 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 Safronov207 const nutBodyMap = new Map<Matter.Body, NutBody>()
74d506c6 2026-04-12 Pavel Safronov208 let splitQueue: NutBody[] = []
209 const splittingIds = new Set<number>()
cfe80f49 2026-04-13 Pavel Safronov210 let popQueue: NutBody[] = []
211 const poppingIds = new Set<number>()
44eb5832 2026-04-12 Pavel Safronov212
213 Composite.add(engine.world, walls)
214
cfe80f49 2026-04-13 Pavel Safronov215 // ── Balloon buoyancy + squish decay ────────────────────────────────────
216 // Balloons float against gravity (2.2× lift) and their squish decays each tick.
f1262fd9 2026-04-12 Pavel Safronov217 Events.on(engine, 'beforeUpdate', () => {
218 const scale = engine.gravity.scale * 2.2
cfe80f49 2026-04-13 Pavel Safronov219 for (const nb of nuts) {
220 if (nb.kind === 'balloon') {
221 // Decay squish (hitCount) toward zero each tick
5dba9120 2026-04-13 Pavel Safronov222 if (nb.state > 0) {
223 nb.state *= BALLOON_SQUISH_DECAY
224 if (nb.state < 0.005) nb.state = 0
cfe80f49 2026-04-13 Pavel Safronov225 }
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 Safronov230 })
cfe80f49 2026-04-13 Pavel Safronov231 } else if (nb.kind === 'stressball') {
5dba9120 2026-04-13 Pavel Safronov232 if (nb.state > 0) {
233 nb.state *= STRESSBALL_SQUISH_DECAY
234 if (nb.state < 0.005) nb.state = 0
cfe80f49 2026-04-13 Pavel Safronov235 }
f1262fd9 2026-04-12 Pavel Safronov236 }
237 }
238 })
239
44eb5832 2026-04-12 Pavel Safronov240 // ── Collision haptics ──────────────────────────────────────────────────
aa6e5a99 2026-04-13 Pavel Safronov241
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 Safronov253 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 Safronov257 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 Safronov259
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 Safronov262 const aRock = pair.bodyA.label === 'rock'
263 const bRock = pair.bodyB.label === 'rock'
a2b3b1f4 2026-04-12 Pavel Safronov264 const gemInvolved = aGem || bGem
f1262fd9 2026-04-12 Pavel Safronov265
74d506c6 2026-04-12 Pavel Safronov266 // 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 Safronov268 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 Safronov273 if (!nb) return
98ba5504 2026-04-13 Pavel Safronov274 // 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 Safronov278 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 Safronov280 splittingIds.add(rockBody.id)
74d506c6 2026-04-12 Pavel Safronov281 splitQueue.push(nb)
282 }
98ba5504 2026-04-13 Pavel Safronov283 if (aRock) tryQueueSplit(pair.bodyA, pair.bodyB)
284 if (bRock) tryQueueSplit(pair.bodyB, pair.bodyA)
74d506c6 2026-04-12 Pavel Safronov285 }
286
cfe80f49 2026-04-13 Pavel Safronov287 // ── 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 Safronov295 const nb = nutBodyMap.get(balloonBody)
cfe80f49 2026-04-13 Pavel Safronov296 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 Safronov299 nb.state = Math.min(nb.state + pressure, 1.5)
300 if (nb.state >= 1.0) {
cfe80f49 2026-04-13 Pavel Safronov301 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 Safronov312 const nb = nutBodyMap.get(sbBody)
cfe80f49 2026-04-13 Pavel Safronov313 if (!nb) return
314 const pressure = Math.pow(speed / 14, 1.2)
5dba9120 2026-04-13 Pavel Safronov315 nb.state = Math.min(nb.state + pressure, 1.0)
cfe80f49 2026-04-13 Pavel Safronov316 }
317 if (aStress) applySquish(pair.bodyA)
318 if (bStress) applySquish(pair.bodyB)
319 }
320
f1262fd9 2026-04-12 Pavel Safronov321 if (aNut && bNut) {
74d506c6 2026-04-12 Pavel Safronov322 // Body-body collision
f1262fd9 2026-04-12 Pavel Safronov323 dbg('nut-nut speed:', speed.toFixed(2))
324 if (speed > CONFIG.impactThreshold) {
aa6e5a99 2026-04-13 Pavel Safronov325 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 Safronov329 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 Safronov332 playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
74d506c6 2026-04-12 Pavel Safronov333 } else if (aGem && bGem) {
a2b3b1f4 2026-04-12 Pavel Safronov334 const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
335 vibrate(Math.round(8 + t * 18))
aa6e5a99 2026-04-13 Pavel Safronov336 playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
a2b3b1f4 2026-04-12 Pavel Safronov337 } 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 Safronov340 playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
a2b3b1f4 2026-04-12 Pavel Safronov341 } else {
342 const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
343 vibrate(Math.round(10 + t * 30))
aa6e5a99 2026-04-13 Pavel Safronov344 playImpact(sndKind, 0.15 + t * 0.85, avgRadius)
a2b3b1f4 2026-04-12 Pavel Safronov345 }
f1262fd9 2026-04-12 Pavel Safronov346 }
347 } else if (aNut || bNut) {
74d506c6 2026-04-12 Pavel Safronov348 // Body-wall collision
f1262fd9 2026-04-12 Pavel Safronov349 const nutBody = aWall ? pair.bodyB : pair.bodyA
5dba9120 2026-04-13 Pavel Safronov350 if (!ITEM_LABELS.has(nutBody.label as never)) continue
74d506c6 2026-04-12 Pavel Safronov351 const wallSpeed = nutBody.speed
aa6e5a99 2026-04-13 Pavel Safronov352 const itemRadius = nutBodyMap.get(nutBody)?.radius ?? 60
74d506c6 2026-04-12 Pavel Safronov353 dbg('wall hit speed:', wallSpeed.toFixed(2))
354 if (wallSpeed > CONFIG.impactThreshold) {
aa6e5a99 2026-04-13 Pavel Safronov355 const sndKind = impactSoundKind(nutBody.label)
74d506c6 2026-04-12 Pavel Safronov356 if (nutBody.label === 'rock') {
357 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1)
98ba5504 2026-04-13 Pavel Safronov358 vibrateWall(Math.round(15 + t * 30))
aa6e5a99 2026-04-13 Pavel Safronov359 playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
74d506c6 2026-04-12 Pavel Safronov360 } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
361 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1)
98ba5504 2026-04-13 Pavel Safronov362 vibrateWall(Math.round(15 + t * 35))
aa6e5a99 2026-04-13 Pavel Safronov363 playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
a2b3b1f4 2026-04-12 Pavel Safronov364 } else {
74d506c6 2026-04-12 Pavel Safronov365 const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1)
98ba5504 2026-04-13 Pavel Safronov366 vibrateWall(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
aa6e5a99 2026-04-13 Pavel Safronov367 playImpact(sndKind, 0.1 + t * 0.7, itemRadius)
a2b3b1f4 2026-04-12 Pavel Safronov368 }
f1262fd9 2026-04-12 Pavel Safronov369 }
44eb5832 2026-04-12 Pavel Safronov370 }
371 }
372 })
373
374 // ── Public API ─────────────────────────────────────────────────────────
375
f1262fd9 2026-04-12 Pavel Safronov376 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 Safronov381 nutBodyMap.set(nut.body, nut)
f1262fd9 2026-04-12 Pavel Safronov382 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 Safronov390 nutBodyMap.set(gem.body, gem)
f1262fd9 2026-04-12 Pavel Safronov391 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 Safronov398 nutBodyMap.set(balloon.body, balloon)
f1262fd9 2026-04-12 Pavel Safronov399 Composite.add(engine.world, balloon.body)
400 }
401
cfe80f49 2026-04-13 Pavel Safronov402 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 Safronov406 nutBodyMap.set(sb.body, sb)
cfe80f49 2026-04-13 Pavel Safronov407 Composite.add(engine.world, sb.body)
408 }
409
a2e3ea1a 2026-04-13 Pavel Safronov410 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 Safronov419 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 Safronov424 nutBodyMap.set(m.body, m)
a2b3b1f4 2026-04-12 Pavel Safronov425 Composite.add(engine.world, m.body)
426 }
427
74d506c6 2026-04-12 Pavel Safronov428 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 Safronov433 nutBodyMap.set(rock.body, rock)
74d506c6 2026-04-12 Pavel Safronov434 Composite.add(engine.world, rock.body)
435 }
436
cfe80f49 2026-04-13 Pavel Safronov437 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 Safronov444 nutBodyMap.delete(nut.body)
cfe80f49 2026-04-13 Pavel Safronov445 Composite.remove(engine.world, nut.body)
446 nuts.splice(idx, 1)
447 vibrate(70) // satisfying pop
aa6e5a99 2026-04-13 Pavel Safronov448 playImpact('balloon_pop', 0.9, nut.radius)
449 world.onRemoved?.(nut.kind, nut.radius)
cfe80f49 2026-04-13 Pavel Safronov450 }
451 }
452
74d506c6 2026-04-12 Pavel Safronov453 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 Safronov460
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 Safronov464 nutBodyMap.delete(nut.body)
74d506c6 2026-04-12 Pavel Safronov465 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 Safronov470 // 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 Safronov477 for (let i = 0; i < count; i++) {
aa6e5a99 2026-04-13 Pavel Safronov478 // 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 Safronov483 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 Safronov491 })
98ba5504 2026-04-13 Pavel Safronov492 nuts.push(frag)
5dba9120 2026-04-13 Pavel Safronov493 nutBodyMap.set(frag.body, frag)
98ba5504 2026-04-13 Pavel Safronov494 Composite.add(engine.world, frag.body)
74d506c6 2026-04-12 Pavel Safronov495 }
496 }
497 }
498
f1262fd9 2026-04-12 Pavel Safronov499 /** 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 Safronov506 nutBodyMap.delete(body)
f1262fd9 2026-04-12 Pavel Safronov507 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 Safronov516 for (const { body } of nuts) Composite.remove(engine.world, body)
5dba9120 2026-04-13 Pavel Safronov517 nutBodyMap.clear()
44eb5832 2026-04-12 Pavel Safronov518 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 Safronov544 processSplits()
cfe80f49 2026-04-13 Pavel Safronov545 processPops()
44eb5832 2026-04-12 Pavel Safronov546 }
547
548 function destroy(): void {
549 Composite.clear(engine.world, false)
550 Engine.clear(engine)
551 }
552
aa6e5a99 2026-04-13 Pavel Safronov553 const world: PhysicsWorld = {
f1262fd9 2026-04-12 Pavel Safronov554 engine,
555 get nuts() { return nuts },
aa6e5a99 2026-04-13 Pavel Safronov556 onRemoved: null,
f1262fd9 2026-04-12 Pavel Safronov557 addNut,
558 addGem,
559 addBalloon,
cfe80f49 2026-04-13 Pavel Safronov560 addStressBall,
a2e3ea1a 2026-04-13 Pavel Safronov561 addCoin,
a2b3b1f4 2026-04-12 Pavel Safronov562 addMetalNut,
74d506c6 2026-04-12 Pavel Safronov563 addRock,
f1262fd9 2026-04-12 Pavel Safronov564 removeAt,
565 clearNuts,
566 resize,
567 applyShakeForce,
568 setGravity,
569 step,
570 destroy,
571 }
aa6e5a99 2026-04-13 Pavel Safronov572 return world
44eb5832 2026-04-12 Pavel Safronov573}