nuts / commit
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)