fuzzyworld

nuts / commit

added new item types and modified how items handle

a2b3b1f4ca7ad2758a4c7e1911f0380d46fd267e · Pavel Safronov · 2026-04-12 11:38:12 -0700 · parents 48f320c19ff5

added new item types and modified how items handle
 src/main.ts        |  5 +++-
 src/persistence.ts |  4 +--
 src/physics.ts     | 82 +++++++++++++++++++++++++++++++++++++++++-----------
 src/renderer.ts    | 84 +++++++++++++++++++++++++++++++++++++++++++++++++++---
 src/share.ts       | 11 +++++--
 src/style.css      |  4 +++
 src/ui.ts          |  5 ++--
 7 files changed, 167 insertions(+), 28 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 6b70c38..19a0c42 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -80,6 +80,7 @@ async function main(): Promise<void> {
                 placed.push({ x, y, r })
                 if (kind === 'gem') physics.addGem(x, y, r, gv)
                 else if (kind === 'balloon') physics.addBalloon(x, y, r, gv)
+                else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
                 else physics.addNut(x, y, r)
             },
             initialHasSpace,
@@ -92,6 +93,7 @@ async function main(): Promise<void> {
                 const y = n.y * cssHeight
                 if (n.kind === 'gem') physics.addGem(x, y, n.radius, n.gemVariant)
                 else if (n.kind === 'balloon') physics.addBalloon(x, y, n.radius, n.colorIndex)
+                else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
                 else physics.addNut(x, y, n.radius)
             }
         }
@@ -136,10 +138,11 @@ async function main(): Promise<void> {
             let kind = currentKind
             if (kind === 'random') {
                 const rand = Math.random()
-                kind = rand < 0.20 ? 'balloon' : rand < 0.45 ? 'gem' : 'nut'
+                kind = rand < 0.20 ? 'balloon' : rand < 0.40 ? 'gem' : rand < 0.55 ? 'metalnut' : 'nut'
             }
             if (kind === 'balloon') physics.addBalloon(cx, cy, currentNutRadius)
             else if (kind === 'gem') physics.addGem(cx, cy, currentNutRadius)
+            else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
             else physics.addNut(cx, cy, currentNutRadius)
         }
     }
diff --git a/src/persistence.ts b/src/persistence.ts
index 2839c97..7195806 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -38,7 +38,7 @@ export interface NutSnapshot {
     angle: number
     radius: number  // absolute px
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
     gemVariant: number
 }
 
@@ -50,7 +50,7 @@ export interface SnapshotRecord {
 }
 
 export function saveSnapshot(
-    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon'; gemVariant: number }>,
+    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut'; gemVariant: number }>,
     screenW: number,
     screenH: number,
 ): void {
diff --git a/src/physics.ts b/src/physics.ts
index 8a0267d..a1f787e 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -11,7 +11,7 @@ export interface NutBody {
     body: Matter.Body
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
     gemVariant: number  // gems: 0=diamond…4=amethyst  balloons: colorIndex
 }
 
@@ -21,6 +21,7 @@ export interface PhysicsWorld {
     addNut: (x: number, y: number, nutRadius?: number) => void
     addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void
     addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void
+    addMetalNut: (x: number, y: number, nutRadius?: number) => void
     removeAt: (x: number, y: number) => boolean
     clearNuts: () => void
     resize: (width: number, height: number) => void
@@ -34,7 +35,7 @@ export interface PhysicsWorld {
 
 function buildWalls(width: number, height: number): Matter.Body[] {
     const t = CONFIG.wallThickness
-    const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.3 }
+    const opts: Matter.IBodyDefinition = { isStatic: true, label: 'wall', friction: 0.3, restitution: 0.9, frictionStatic: 0.3 }
     return [
         Bodies.rectangle(width / 2, -t / 2, width + t * 2, t, opts), // top
         Bodies.rectangle(width / 2, height + t / 2, width + t * 2, t, opts), // bottom
@@ -65,6 +66,26 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number):
     return { body, radius, colorIndex, kind: 'nut', gemVariant: 0 }
 }
 
+function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody {
+    const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
+    const radius = nutRadius * variance
+    const ry = radius / CONFIG.nutAspectRatio
+    const N = 20
+    const verts: Matter.Vector[] = []
+    for (let i = 0; i < N; i++) {
+        const a = (i / N) * Math.PI * 2
+        verts.push(Vector.create(Math.cos(a) * radius, Math.sin(a) * ry))
+    }
+    const body = Bodies.fromVertices(x, y, [verts], {
+        restitution: 0.92,
+        friction: 0,
+        frictionStatic: 0,
+        frictionAir: 0.0008,
+        label: 'metalnut',
+    })
+    return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0 }
+}
+
 const GEM_SIDES = [4, 6, 6, 8, 6] as const  // sides per gem variant
 export const GEM_VARIANT_COUNT = GEM_SIDES.length
 export const BALLOON_COLOR_COUNT = 7
@@ -74,9 +95,10 @@ function spawnGem(x: number, y: number, radius: number, gemVariant: number): Nut
     const actualRadius = radius * variance
     const sides = GEM_SIDES[gemVariant % GEM_SIDES.length]
     const body = Bodies.polygon(x, y, sides, actualRadius, {
-        restitution: CONFIG.restitution,
-        friction: CONFIG.friction,
-        frictionAir: CONFIG.frictionAir,
+        restitution: 0.95,
+        friction: 0,
+        frictionStatic: 0,
+        frictionAir: 0.0005,
         label: 'gem',
     })
     return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant }
@@ -128,28 +150,47 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         for (const pair of event.pairs) {
             const aWall = pair.bodyA.label === 'wall'
             const bWall = pair.bodyB.label === 'wall'
-            const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon'
-            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon'
+            const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut'
+            const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut'
+
+            const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut'
+            const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut'
+            const gemInvolved = aGem || bGem
 
             if (aNut && bNut) {
-                // Nut/gem-nut/gem collision – softer haptic
+                // Nut/gem-nut/gem collision
                 const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed)
                 dbg('nut-nut speed:', speed.toFixed(2))
                 if (speed > CONFIG.impactThreshold) {
-                    const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
-                    const ms = Math.round(10 + t * 30)
-                    vibrate(ms)
+                    if (aGem && bGem) {
+                        // gem/metal-gem/metal: sharp short clack
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
+                        vibrate(Math.round(8 + t * 18))
+                    } else if (gemInvolved) {
+                        // gem/metal-nut: medium clack
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1)
+                        vibrate(Math.round(10 + t * 22))
+                    } else {
+                        // nut-nut: original soft
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                        vibrate(Math.round(10 + t * 30))
+                    }
                 }
             } else if (aNut || bNut) {
-                // Nut/gem-wall collision – stronger haptic
+                // body-wall collision
                 const nutBody = aWall ? pair.bodyB : pair.bodyA
-                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon') continue
+                if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut') continue
                 const speed = nutBody.speed
                 dbg('wall hit speed:', speed.toFixed(2))
                 if (speed > CONFIG.impactThreshold) {
-                    const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
-                    const ms = Math.round(30 + t * (CONFIG.maxVibrationMs - 30))
-                    vibrate(ms)
+                    if (nutBody.label === 'gem' || nutBody.label === 'metalnut') {
+                        // gem/metal-wall: sharp hard clack
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1)
+                        vibrate(Math.round(15 + t * 35))
+                    } else {
+                        const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1)
+                        vibrate(Math.round(30 + t * (CONFIG.maxVibrationMs - 30)))
+                    }
                 }
             }
         }
@@ -180,6 +221,14 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, balloon.body)
     }
 
+    function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
+        const colorIndex = nuts.length
+        const m = spawnMetalNut(x, y, nutRadius, colorIndex)
+        Body.setAngle(m.body, Math.random() * Math.PI * 2)
+        nuts.push(m)
+        Composite.add(engine.world, m.body)
+    }
+
     /** Remove the topmost body whose bounding circle contains (x, y). Returns true if one was removed. */
     function removeAt(x: number, y: number): boolean {
         for (let i = nuts.length - 1; i >= 0; i--) {
@@ -238,6 +287,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         addNut,
         addGem,
         addBalloon,
+        addMetalNut,
         removeAt,
         clearNuts,
         resize,
diff --git a/src/renderer.ts b/src/renderer.ts
index 505b2ac..af4ce56 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -12,6 +12,14 @@ const PALETTE: NutColor[] = [
     { hi: '#DDB860', mid: '#B88E30', shadow: '#785808', rim: '#503A04' }, // light chestnut
 ]
 
+const METAL_PALETTE: NutColor[] = [
+    { hi: '#FFFFFF', mid: '#B8C0CC', shadow: '#505868', rim: '#303848' }, // chrome
+    { hi: '#E0E8F0', mid: '#8898AC', shadow: '#404E5E', rim: '#283040' }, // steel
+    { hi: '#D0D8E0', mid: '#707880', shadow: '#3A4048', rim: '#22282E' }, // gunmetal
+    { hi: '#E8E4D8', mid: '#A0A090', shadow: '#585850', rim: '#383830' }, // pewter
+    { hi: '#F4F0E8', mid: '#C8C0A8', shadow: '#706858', rim: '#504840' }, // silver-warm
+]
+
 // ── Cache pre-rendered sprites ─────────────────────────────────────────────
 const spriteCache = new Map<string, HTMLCanvasElement>()
 
@@ -203,14 +211,82 @@ function buildSprite(radius: number, colorIndex: number): HTMLCanvasElement {
     return oc
 }
 
-function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem', gemVariant: number): HTMLCanvasElement {
+function buildMetalSprite(radius: number, colorIndex: number): HTMLCanvasElement {
+    const c = METAL_PALETTE[colorIndex % METAL_PALETTE.length]
+    const rx = radius
+    const ry = Math.round(radius / CONFIG.nutAspectRatio)
+    const pad = Math.ceil(rx * 0.35)
+    const sizeW = Math.ceil(rx * 2 + pad * 2)
+    const sizeH = Math.ceil(ry * 2 + pad * 2)
+    const oc = document.createElement('canvas')
+    oc.width = sizeW
+    oc.height = sizeH
+    const ctx = oc.getContext('2d')!
+    const cx = sizeW / 2
+    const cy = sizeH / 2
+
+    // Drop shadow
+    ctx.save()
+    ctx.filter = `blur(${Math.ceil(pad * 0.55)}px)`
+    ctx.globalAlpha = 0.6
+    ctx.fillStyle = '#000'
+    ctx.beginPath()
+    ctx.ellipse(cx + pad * 0.1, cy + pad * 0.3, rx * 0.88, ry * 0.88, 0, 0, Math.PI * 2)
+    ctx.fill()
+    ctx.restore()
+
+    // Base metallic radial gradient – high contrast for chrome look
+    const grad = ctx.createRadialGradient(
+        cx - rx * 0.3, cy - ry * 0.38, rx * 0.02,
+        cx, cy, rx
+    )
+    grad.addColorStop(0, c.hi)
+    grad.addColorStop(0.25, c.mid)
+    grad.addColorStop(0.6, c.shadow)
+    grad.addColorStop(0.85, c.mid)
+    grad.addColorStop(1, c.rim)
+    ctx.beginPath()
+    ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Rim stroke
+    ctx.lineWidth = Math.max(1.5, rx * 0.045)
+    ctx.strokeStyle = c.rim
+    ctx.stroke()
+
+    // Broad specular band – the key metallic look
+    const specGrad = ctx.createLinearGradient(cx - rx * 0.5, cy - ry * 0.6, cx + rx * 0.1, cy + ry * 0.1)
+    specGrad.addColorStop(0, 'rgba(255,255,255,0.0)')
+    specGrad.addColorStop(0.3, 'rgba(255,255,255,0.45)')
+    specGrad.addColorStop(0.6, 'rgba(255,255,255,0.22)')
+    specGrad.addColorStop(1, 'rgba(255,255,255,0.0)')
+    ctx.beginPath()
+    ctx.ellipse(cx, cy, rx, ry, 0, 0, Math.PI * 2)
+    ctx.fillStyle = specGrad
+    ctx.fill()
+
+    // Sharp bright specular dot
+    ctx.beginPath()
+    ctx.ellipse(cx - rx * 0.28, cy - ry * 0.33, rx * 0.18, ry * 0.11, -0.4, 0, Math.PI * 2)
+    ctx.fillStyle = 'rgba(255,255,255,0.72)'
+    ctx.fill()
+
+    return oc
+}
+
+function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'metalnut', gemVariant: number): HTMLCanvasElement {
     const r = roundToNearest4(radius)
     const key = kind === 'gem'
         ? `gem_${r}_${gemVariant % GEM_TYPES.length}`
-        : `nut_${r}_${colorIndex % PALETTE.length}`
+        : kind === 'metalnut'
+            ? `metalnut_${r}_${colorIndex % METAL_PALETTE.length}`
+            : `nut_${r}_${colorIndex % PALETTE.length}`
     let sprite = spriteCache.get(key)
     if (!sprite) {
-        sprite = kind === 'gem' ? buildGemSprite(r, gemVariant) : buildSprite(r, colorIndex)
+        sprite = kind === 'gem' ? buildGemSprite(r, gemVariant)
+            : kind === 'metalnut' ? buildMetalSprite(r, colorIndex)
+                : buildSprite(r, colorIndex)
         spriteCache.set(key, sprite)
     }
     return sprite
@@ -379,7 +455,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 drawBalloon(ctx, 0, 0, radius, colorIndex)
                 ctx.restore()
             } else {
-                const sprite = getSprite(radius, colorIndex, kind, gemVariant)
+                const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
                 ctx.save()
                 ctx.translate(position.x, position.y)
                 ctx.rotate(angle)
diff --git a/src/share.ts b/src/share.ts
index 62b9dcf..da51cb8 100644
--- a/src/share.ts
+++ b/src/share.ts
@@ -10,7 +10,7 @@ import type { NutBody } from './physics'
 export interface SharedNut {
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut'
     gemVariant: number
 }
 
@@ -23,6 +23,7 @@ export function encodeNuts(nuts: NutBody[]): string {
     return nuts.map(n => {
         if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}`
         if (n.kind === 'balloon') return `r${Math.round(n.radius)}b${n.colorIndex % 7}`
+        if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}`
         return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
     }).join(',')
 }
@@ -35,6 +36,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null {
             if (gem) return { radius: parseInt(gem[1], 10), colorIndex: 0, kind: 'gem' as const, gemVariant: parseInt(gem[2], 10) }
             const balloon = part.match(/^r(\d+)b(\d+)$/)
             if (balloon) return { radius: parseInt(balloon[1], 10), colorIndex: parseInt(balloon[2], 10), kind: 'balloon' as const, gemVariant: parseInt(balloon[2], 10) }
+            const metal = part.match(/^r(\d+)m(\d+)$/)
+            if (metal) return { radius: parseInt(metal[1], 10), colorIndex: parseInt(metal[2], 10), kind: 'metalnut' as const, gemVariant: 0 }
             const nut = part.match(/^r(\d+)c(\d+)$/)
             if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 }
             throw new Error('invalid')
@@ -61,7 +64,9 @@ export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
             ? `r${Math.round(n.radius)}g${n.gemVariant}`
             : n.kind === 'balloon'
                 ? `r${Math.round(n.radius)}b${n.colorIndex % 7}`
-                : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
+                : n.kind === 'metalnut'
+                    ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
+                    : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
         const candidate = base + encodeURIComponent(',' + [...parts, token].join(','))
         if (candidate.length > MAX_URL_LENGTH) break
         parts.push(token)
@@ -100,7 +105,7 @@ export function placeSharedNuts(
     nuts: SharedNut[],
     screenW: number,
     screenH: number,
-    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon', gemVariant: number) => void,
+    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut', gemVariant: number) => void,
     hasSpace: (x: number, y: number, r: number) => boolean,
 ): void {
     for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) {
diff --git a/src/style.css b/src/style.css
index a067e82..99f056e 100644
--- a/src/style.css
+++ b/src/style.css
@@ -163,6 +163,10 @@ body {
     color: #FF4466;
 }
 
+.toolbar-btn.kind-metalnut {
+    color: #C0C8D8;
+}
+
 .toolbar-btn.kind-random {
     color: #aaa;
 }
diff --git a/src/ui.ts b/src/ui.ts
index 577b6d7..34c2871 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -2,14 +2,15 @@ import { CONFIG } from './config'
 import { dbg } from './debug'
 import QRCode from 'qrcode'
 
-export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon'
+export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut'
 
-const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon']
+const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut']
 const KIND_ICON: Record<ItemKind, string> = {
     random: '?',
     nut: '🌰',
     gem: '💎',
     balloon: '🎈',
+    metalnut: '🔩',
 }
 
 export interface UICallbacks {