fuzzyworld

nuts / commit

adding goo and stickiness

909551ba95412ff4e3bdc612528ee5581711a866 · Pavel Safronov · 2026-04-12 18:25:04 -0700 · parents cfe80f49f345

adding goo and stickiness
 src/main.ts     |   2 +-
 src/physics.ts  | 152 ++++++++++++++++++++++++++++++++++++++++++++++++++++----
 src/renderer.ts |  50 +++++++++++++++++--
 3 files changed, 190 insertions(+), 14 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 58503c3..3545126 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -412,7 +412,7 @@ async function main(): Promise<void> {
         physics.step(delta)
 
         const g = physics.engine.gravity
-        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, gravityMode !== 'device')
+        renderer.render(physics.nuts, Math.atan2(g.y, g.x) - Math.PI / 2, gravityMode !== 'device', physics.gooConstraints)
 
         // Save snapshot every ~3 s
         if (Math.floor(timestamp / 3000) !== Math.floor((timestamp - delta) / 3000)) {
diff --git a/src/physics.ts b/src/physics.ts
index e4beb25..bdb8176 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -4,7 +4,7 @@ import { vibrate, vibrateWall } from './vibration'
 import { dbg } from './debug'
 import { ROCK_TEMPLATES } from './rockTemplates'
 
-const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
+const { Engine, Bodies, Body, Composite, Events, Vector, Constraint } = Matter
 
 // ── Types ──────────────────────────────────────────────────────────────────
 
@@ -17,6 +17,16 @@ export interface NutBody {
     hitCount: number    // rocks: accumulated damage
     templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks
     toughness: number   // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks
+    stickyAmount: number // 0 = clean, 1 = fully coated in goo
+}
+
+export interface GooConstraint {
+    bodyA: Matter.Body
+    bodyB: Matter.Body
+    nutA: NutBody
+    nutB: NutBody
+    constraint: Matter.Constraint
+    strength: number     // 1.0 = fresh bond → 0 = broken
 }
 
 export interface PhysicsWorld {
@@ -33,6 +43,7 @@ export interface PhysicsWorld {
     resize: (width: number, height: number) => void
     applyShakeForce: (intensity: number) => void
     setGravity: (gx: number, gy: number) => void
+    get gooConstraints(): GooConstraint[]
     step: (deltaMs: number) => void
     destroy: () => void
 }
@@ -70,7 +81,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number):
         frictionAir: CONFIG.frictionAir,
         label: 'nut',
     })
-    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
 }
 
 function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
@@ -84,7 +95,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb
         frictionAir: 0.0008,
         label: 'metalnut',
     })
-    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
 }
 
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
@@ -113,7 +124,7 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut
         frictionAir: 0.0005,
         label: 'gem',
     })
-    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
 }
 
 const BALLOON_POP_SPEED = 18     // speed (px/tick) that instantly pops a balloon
@@ -129,10 +140,18 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number):
         frictionAir: 0.006,
         label: 'balloon',
     })
-    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
 }
 
 const STRESSBALL_SQUISH_DECAY = 0.80  // faster recovery than balloon
+const STRESSBALL_EXPLODE_PRESSURE = 1.0
+const STRESSBALL_EXPLODE_RADIUS_MULT = 3.2  // how far the goo splash reaches
+
+const GOO_BOND_MIN_STICKY = 0.25  // both bodies need at least this to bond
+const GOO_BOND_MAX = 20           // max simultaneous constraints (physics budget)
+const GOO_BOND_BREAK_SPEED = 7   // px/tick threshold for bond damage
+const GOO_STRENGTH_DECAY = 0.0004 // natural bond decay per physics tick
+const STICKY_DECAY = 0.9994       // stickyAmount decay per tick (unbonded)
 
 function spawnStressBall(x: number, y: number, radius: number, colorIndex: number): NutBody {
     const variance = 0.7 + Math.random() * 0.6  // 0.7× to 1.3× base radius
@@ -144,7 +163,7 @@ function spawnStressBall(x: number, y: number, radius: number, colorIndex: numbe
         frictionAir: 0.015,
         label: 'stressball',
     })
-    return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 }
+    return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1, stickyAmount: 0 }
 }
 
 // ── Factory ────────────────────────────────────────────────────────────────
@@ -169,7 +188,7 @@ function spawnRock(x: number, y: number, targetRadius: number, colorIndex: numbe
     })
     // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard)
     const toughness = Math.pow(2, (Math.random() - 0.5) * 2)  // 2^[-1, 1] = 0.5 … 2.0
-    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness }
+    return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness, stickyAmount: 0 }
 }
 
 export function createPhysicsWorld(width: number, height: number): PhysicsWorld {
@@ -185,6 +204,9 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     const splittingIds = new Set<number>()
     let popQueue: NutBody[] = []
     const poppingIds = new Set<number>()
+    let gooConstraints: GooConstraint[] = []
+    let explosionQueue: NutBody[] = []
+    const explodingIds = new Set<number>()
 
     Composite.add(engine.world, walls)
 
@@ -211,6 +233,33 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 }
             }
         }
+
+        // ── Goo bond decay + sticky body decay ──────────────────────────
+        // Age each bond; broken ones are removed and partners lose stickiness.
+        const brokenBonds: GooConstraint[] = []
+        for (const gc of gooConstraints) {
+            gc.strength -= GOO_STRENGTH_DECAY
+            if (gc.strength <= 0) brokenBonds.push(gc)
+        }
+        for (const gc of brokenBonds) {
+            Composite.remove(engine.world, gc.constraint)
+            gooConstraints = gooConstraints.filter(x => x !== gc)
+            // If partner now has no bonds left, halve their stickiness
+            if (!gooConstraints.some(x => x.bodyA === gc.bodyA || x.bodyB === gc.bodyA))
+                gc.nutA.stickyAmount *= 0.45
+            if (!gooConstraints.some(x => x.bodyA === gc.bodyB || x.bodyB === gc.bodyB))
+                gc.nutB.stickyAmount *= 0.45
+        }
+        // Unbonded sticky bodies lose their goo over time
+        for (const nb of nuts) {
+            if (nb.stickyAmount > 0) {
+                const bonded = gooConstraints.some(gc => gc.bodyA === nb.body || gc.bodyB === nb.body)
+                if (!bonded) {
+                    nb.stickyAmount *= STICKY_DECAY
+                    if (nb.stickyAmount < 0.01) nb.stickyAmount = 0
+                }
+            }
+        }
     })
 
     // ── Collision haptics ──────────────────────────────────────────────────
@@ -270,18 +319,58 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
                 if (bBalloon) applyPressure(pair.bodyB)
             }
 
-            // ── Stress ball squish (no pop) ─────────────────────────────
+            // ── Stress ball squish / explode ────────────────────────────
             if (aStress || bStress) {
                 const applySquish = (sbBody: Matter.Body) => {
+                    if (explodingIds.has(sbBody.id)) return
                     const nb = nuts.find(n => n.body === sbBody)
                     if (!nb) return
                     const pressure = Math.pow(speed / 14, 1.2)
-                    nb.hitCount = Math.min(nb.hitCount + pressure, 1.0)
+                    nb.hitCount += pressure
+                    if (nb.hitCount >= STRESSBALL_EXPLODE_PRESSURE) {
+                        explodingIds.add(sbBody.id)
+                        explosionQueue.push(nb)
+                    }
                 }
                 if (aStress) applySquish(pair.bodyA)
                 if (bStress) applySquish(pair.bodyB)
             }
 
+            // ── Goo bond formation (sticky bodies touching each other) ───────
+            if (aNut && bNut && gooConstraints.length < GOO_BOND_MAX) {
+                const nbA = nuts.find(n => n.body === pair.bodyA)
+                const nbB = nuts.find(n => n.body === pair.bodyB)
+                if (nbA && nbB &&
+                    nbA.stickyAmount >= GOO_BOND_MIN_STICKY &&
+                    nbB.stickyAmount >= GOO_BOND_MIN_STICKY &&
+                    !gooConstraints.some(gc =>
+                        (gc.bodyA === pair.bodyA && gc.bodyB === pair.bodyB) ||
+                        (gc.bodyA === pair.bodyB && gc.bodyB === pair.bodyA))) {
+                    const c = Constraint.create({
+                        bodyA: pair.bodyA,
+                        bodyB: pair.bodyB,
+                        stiffness: 0.85,
+                        damping: 0.18,
+                        label: 'goo',
+                    })
+                    Composite.add(engine.world, c)
+                    gooConstraints.push({ bodyA: pair.bodyA, bodyB: pair.bodyB, nutA: nbA, nutB: nbB, constraint: c, strength: 1.0 })
+                }
+            }
+
+            // ── Goo bond damage (hard hits weaken bonds) ────────────────
+            if (speed > GOO_BOND_BREAK_SPEED) {
+                const damageBodyBonds = (b: Matter.Body) => {
+                    const excess = speed - GOO_BOND_BREAK_SPEED
+                    const dmg = (excess * excess) * 0.014
+                    for (const gc of gooConstraints) {
+                        if (gc.bodyA === b || gc.bodyB === b) gc.strength -= dmg
+                    }
+                }
+                if (aNut) damageBodyBonds(pair.bodyA)
+                if (bNut) damageBodyBonds(pair.bodyB)
+            }
+
             if (aNut && bNut) {
                 // Body-body collision
                 dbg('nut-nut speed:', speed.toFixed(2))
@@ -371,6 +460,42 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, rock.body)
     }
 
+    function removeBodyBonds(body: Matter.Body): void {
+        const toRemove = gooConstraints.filter(gc => gc.bodyA === body || gc.bodyB === body)
+        for (const gc of toRemove) Composite.remove(engine.world, gc.constraint)
+        gooConstraints = gooConstraints.filter(gc => gc.bodyA !== body && gc.bodyB !== body)
+    }
+
+    function processExplosions(): void {
+        if (explosionQueue.length === 0) return
+        const toExplode = explosionQueue.splice(0)
+        for (const sb of toExplode) {
+            explodingIds.delete(sb.body.id)
+            const idx = nuts.indexOf(sb)
+            if (idx === -1) continue
+            const pos = { x: sb.body.position.x, y: sb.body.position.y }
+            const splashR = sb.radius * STRESSBALL_EXPLODE_RADIUS_MULT
+            removeBodyBonds(sb.body)
+            Composite.remove(engine.world, sb.body)
+            nuts.splice(idx, 1)
+            // Coat nearby bodies in goo + small scatter impulse
+            for (const nb of nuts) {
+                const dx = nb.body.position.x - pos.x
+                const dy = nb.body.position.y - pos.y
+                const dist = Math.hypot(dx, dy)
+                if (dist < splashR + nb.radius) {
+                    const intensity = Math.max(0, 1 - (dist - nb.radius) / splashR)
+                    nb.stickyAmount = Math.min(1, nb.stickyAmount + intensity)
+                    if (dist > 1) {
+                        const force = nb.body.mass * 0.004 * intensity
+                        Body.applyForce(nb.body, nb.body.position, { x: (dx / dist) * force, y: (dy / dist) * force })
+                    }
+                }
+            }
+            vibrate(65)
+        }
+    }
+
     function processPops(): void {
         if (popQueue.length === 0) return
         const toPop = popQueue.splice(0)
@@ -398,6 +523,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             Composite.remove(engine.world, nut.body)
             nuts.splice(idx, 1)
 
+            removeBodyBonds(nut.body)
+
             if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue
 
             // Spawn smaller rocks preserving total area (≈ mass at constant density)
@@ -431,6 +558,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
             const dx = body.position.x - x
             const dy = body.position.y - y
             if (dx * dx + dy * dy <= radius * radius) {
+                removeBodyBonds(body)
                 Composite.remove(engine.world, body)
                 nuts.splice(i, 1)
                 return true
@@ -441,6 +569,10 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
     function clearNuts(): void {
         for (const { body } of nuts) Composite.remove(engine.world, body)
+        for (const gc of gooConstraints) Composite.remove(engine.world, gc.constraint)
+        gooConstraints = []
+        explosionQueue = []
+        explodingIds.clear()
         nuts = []
     }
 
@@ -469,6 +601,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
 
     function step(deltaMs: number): void {
         Engine.update(engine, Math.min(deltaMs, 50))
+        processExplosions()
         processSplits()
         processPops()
     }
@@ -481,6 +614,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
     return {
         engine,
         get nuts() { return nuts },
+        get gooConstraints() { return gooConstraints },
         addNut,
         addGem,
         addBalloon,
diff --git a/src/renderer.ts b/src/renderer.ts
index d4a8e27..60e2add 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -1,4 +1,4 @@
-import type { NutBody } from './physics'
+import type { NutBody, GooConstraint } from './physics'
 import Matter from 'matter-js'
 import { CONFIG } from './config'
 import QRCode from 'qrcode'
@@ -625,7 +625,7 @@ function drawBalloon(
 // ── Renderer ───────────────────────────────────────────────────────────────
 
 export interface Renderer {
-    render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean) => void
+    render: (nuts: NutBody[], gravityAngle: number, showArrow?: boolean, gooConstraints?: GooConstraint[]) => void
     setShareURL: (url: string) => void
     resize: (width: number, height: number) => void
 }
@@ -662,7 +662,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         }
     }
 
-    function render(nuts: NutBody[], gravityAngle: number, showArrow = false): void {
+    function render(nuts: NutBody[], gravityAngle: number, showArrow = false, gooConstraints: GooConstraint[] = []): void {
         const w = canvas.width
         const h = canvas.height
 
@@ -670,8 +670,35 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
         ctx.fillStyle = '#111111'
         ctx.fillRect(0, 0, w, h)
 
+        // ── Goo bonds (drawn behind bodies) ────────────────────────────────
+        for (const gc of gooConstraints) {
+            const ax = gc.bodyA.position.x, ay = gc.bodyA.position.y
+            const bx = gc.bodyB.position.x, by = gc.bodyB.position.y
+            const alpha = Math.max(0, gc.strength)
+            const lineW = Math.min(gc.nutA.radius, gc.nutB.radius) * 0.38
+            // Goo droop blob — a fat translucent stroke between centers
+            ctx.save()
+            ctx.globalAlpha = alpha * 0.88
+            ctx.strokeStyle = '#7deb28'
+            ctx.lineWidth = lineW
+            ctx.lineCap = 'round'
+            ctx.beginPath()
+            ctx.moveTo(ax, ay)
+            ctx.lineTo(bx, by)
+            ctx.stroke()
+            // Brighter inner line
+            ctx.globalAlpha = alpha * 0.55
+            ctx.strokeStyle = '#c8ff80'
+            ctx.lineWidth = lineW * 0.4
+            ctx.beginPath()
+            ctx.moveTo(ax, ay)
+            ctx.lineTo(bx, by)
+            ctx.stroke()
+            ctx.restore()
+        }
+
         // Nuts, gems & balloons
-        for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx } of nuts) {
+        for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx, stickyAmount } of nuts) {
             const { position, angle } = body
 
             if (kind === 'balloon') {
@@ -718,6 +745,21 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2, sprite.width, sprite.height)
                 ctx.restore()
             }
+
+            // ── Goo sticky overlay ────────────────────────────────────────
+            if (stickyAmount > 0.01) {
+                const cx = body.position.x, cy = body.position.y
+                ctx.save()
+                const gooGrad = ctx.createRadialGradient(cx, cy, radius * 0.5, cx, cy, radius * 1.15)
+                gooGrad.addColorStop(0, 'rgba(125,235,40,0)')
+                gooGrad.addColorStop(0.65, `rgba(125,235,40,${(stickyAmount * 0.30).toFixed(2)})`)
+                gooGrad.addColorStop(1, `rgba(125,235,40,${(stickyAmount * 0.72).toFixed(2)})`)
+                ctx.fillStyle = gooGrad
+                ctx.beginPath()
+                ctx.arc(cx, cy, radius * 1.15, 0, Math.PI * 2)
+                ctx.fill()
+                ctx.restore()
+            }
         }
 
         // Gravity direction arrow (shown in auto-gravity modes)