nuts / goo / files / src / physics.ts
Branch goo · default branch · all branches
physics.ts
| 1 | import Matter from 'matter-js' |
| 2 | import { CONFIG } from './config' |
| 3 | import { vibrate, vibrateWall } from './vibration' |
| 4 | import { dbg } from './debug' |
| 5 | import { ROCK_TEMPLATES } from './rockTemplates' |
| 6 | |
| 7 | const { Engine, Bodies, Body, Composite, Events, Vector, Constraint } = Matter |
| 8 | |
| 9 | // ── Types ────────────────────────────────────────────────────────────────── |
| 10 | |
| 11 | export 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 | |
| 23 | export 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 | |
| 32 | export 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 | |
| 53 | function 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 | |
| 64 | function 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 | |
| 87 | function 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 | |
| 101 | const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant |
| 102 | export const GEM_VARIANT_COUNT = GEM_SIDES.length |
| 103 | export const BALLOON_COLOR_COUNT = 7 |
| 104 | export const STRESSBALL_COLOR_COUNT = 6 |
| 105 | |
| 106 | const STRESSBALL_COLORS = [ |
| 107 | '#e05050', // red |
| 108 | '#e07830', // orange |
| 109 | '#d4c020', // yellow |
| 110 | '#50b050', // green |
| 111 | '#4090d0', // blue |
| 112 | '#9060c0', // purple |
| 113 | ] |
| 114 | |
| 115 | function 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 | |
| 130 | const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon |
| 131 | const BALLOON_SQUISH_DECAY = 0.86 // per-physics-tick squish decay multiplier |
| 132 | |
| 133 | function 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 | |
| 146 | const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon |
| 147 | const STRESSBALL_EXPLODE_PRESSURE = 1.0 |
| 148 | const STRESSBALL_EXPLODE_RADIUS_MULT = 3.2 // how far the goo splash reaches |
| 149 | |
| 150 | const GOO_BOND_MIN_STICKY = 0.25 // both bodies need at least this to bond |
| 151 | const GOO_BOND_MAX = 20 // max simultaneous constraints (physics budget) |
| 152 | const GOO_BOND_BREAK_SPEED = 7 // px/tick threshold for bond damage |
| 153 | const GOO_STRENGTH_DECAY = 0.0004 // natural bond decay per physics tick |
| 154 | const STICKY_DECAY = 0.9994 // stickyAmount decay per tick (unbonded) |
| 155 | |
| 156 | function 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 | |
| 171 | const ROCK_COLORS_COUNT = 4 |
| 172 | const ROCK_SPLIT_SPEED = 6 // min px/tick to count as a hit |
| 173 | const ROCK_HITS_TO_SPLIT = 10 // hits required to break |
| 174 | |
| 175 | function 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 | |
| 194 | export 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 | } |