fuzzyworld

nuts / commit

added coins

a2e3ea1a63033574006b90ab189e0b16db221e2c · Pavel Safronov · 2026-04-12 20:06:13 -0700 · parents 5dba9120a450

added coins
 src/main.ts        |   7 ++-
 src/persistence.ts |   4 +-
 src/physics.ts     |  43 ++++++++++++++-
 src/renderer.ts    | 154 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 src/share.ts       |   7 ++-
 src/ui.ts          |   6 ++-
 6 files changed, 211 insertions(+), 10 deletions(-)

diff --git a/src/main.ts b/src/main.ts
index 3efbf4f..66344c3 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -55,7 +55,7 @@ async function main(): Promise<void> {
     let vibrateItemsEnabled = true
     let vibrateEdgesEnabled = true
     let currentKind: ItemKind = 'random'
-    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
+    const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
     let randomPool: ItemKind[] = [...DEFAULT_RANDOM_POOL]
 
     // ── Restore persisted settings ────────────────────────────────────────
@@ -77,7 +77,7 @@ async function main(): Promise<void> {
             vibrateEdgesEnabled = false
             setVibrateEdges(false)
         }
-        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
+        const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
         if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) {
             currentKind = savedSettings.kind as ItemKind
         }
@@ -112,6 +112,7 @@ async function main(): Promise<void> {
                 else if (kind === 'metalnut') physics.addMetalNut(x, y, r)
                 else if (kind === 'rock') physics.addRock(x, y, r)
                 else if (kind === 'stressball') physics.addStressBall(x, y, r, gv)
+                else if (kind === 'coin') physics.addCoin(x, y, r, gv)
                 else physics.addNut(x, y, r)
             },
             initialHasSpace,
@@ -127,6 +128,7 @@ async function main(): Promise<void> {
                 else if (n.kind === 'metalnut') physics.addMetalNut(x, y, n.radius)
                 else if (n.kind === 'rock') physics.addRock(x, y, n.radius)
                 else if (n.kind === 'stressball') physics.addStressBall(x, y, n.radius, n.colorIndex)
+                else if (n.kind === 'coin') physics.addCoin(x, y, n.radius, n.gemVariant)
                 else physics.addNut(x, y, n.radius)
             }
         }
@@ -208,6 +210,7 @@ async function main(): Promise<void> {
             else if (kind === 'metalnut') physics.addMetalNut(cx, cy, currentNutRadius)
             else if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius)
             else if (kind === 'stressball') physics.addStressBall(cx, cy, currentNutRadius)
+            else if (kind === 'coin') physics.addCoin(cx, cy, currentNutRadius)
             else physics.addNut(cx, cy, currentNutRadius)
         }
     }
diff --git a/src/persistence.ts b/src/persistence.ts
index 3680e7e..0a47fd1 100644
--- a/src/persistence.ts
+++ b/src/persistence.ts
@@ -44,7 +44,7 @@ export interface NutSnapshot {
     angle: number
     radius: number  // absolute px
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
     gemVariant: number
 }
 
@@ -56,7 +56,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' | 'metalnut' | 'rock' | 'stressball'; gemVariant: number }>,
+    nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'; gemVariant: number }>,
     screenW: number,
     screenH: number,
 ): void {
diff --git a/src/physics.ts b/src/physics.ts
index 25841cf..ec62e3a 100644
--- a/src/physics.ts
+++ b/src/physics.ts
@@ -7,7 +7,7 @@ import { ROCK_TEMPLATES } from './rockTemplates'
 const { Engine, Bodies, Body, Composite, Events, Vector } = Matter
 
 // ── Item label set (single source of truth for "is this an item body?") ────
-export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] as const)
+export const ITEM_LABELS = new Set(['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin'] as const)
 
 // ── Types ──────────────────────────────────────────────────────────────────
 
@@ -15,7 +15,7 @@ export interface NutBody {
     body: Matter.Body
     radius: number
     colorIndex: number
-    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
     gemVariant: number  // gems: 0=diamond…4=amethyst; always 0 for non-gems
     state: number    // rocks: damage (0→10+); balloons/stressballs: squish (0→1); 0 for others
     templateIdx: number // rocks: which RockTemplate to use; 0 for non-rocks
@@ -29,6 +29,7 @@ export interface PhysicsWorld {
     addGem: (x: number, y: number, radius?: number, gemVariant?: number) => void
     addBalloon: (x: number, y: number, radius?: number, colorIndex?: number) => void
     addStressBall: (x: number, y: number, radius?: number, colorIndex?: number) => void
+    addCoin: (x: number, y: number, radius?: number, materialIdx?: number) => void
     addMetalNut: (x: number, y: number, nutRadius?: number) => void
     addRock: (x: number, y: number, radius?: number) => void
     removeAt: (x: number, y: number) => boolean
@@ -94,6 +95,18 @@ 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
 export const STRESSBALL_COLOR_COUNT = 6
+export const COIN_MATERIAL_COUNT = 6
+export const COIN_ANIMAL_COUNT = 10
+
+// Per-material physics differ: density drives heft, restitution the bounce character
+const COIN_PHYSICS = [
+    { density: 0.0095, restitution: 0.42 },  // 0 gold
+    { density: 0.0070, restitution: 0.45 },  // 1 silver
+    { density: 0.0062, restitution: 0.38 },  // 2 copper
+    { density: 0.0058, restitution: 0.35 },  // 3 bronze
+    { density: 0.0062, restitution: 0.40 },  // 4 nickel
+    { density: 0.0140, restitution: 0.40 },  // 5 platinum
+] as const
 
 function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody {
     const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance
@@ -128,6 +141,22 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number):
 
 const STRESSBALL_SQUISH_DECAY = 0.80  // faster recovery than balloon
 
+function spawnCoin(x: number, y: number, radius: number, materialIdx: number): NutBody {
+    const mat = COIN_PHYSICS[materialIdx % COIN_PHYSICS.length]
+    const variance = 0.55 + Math.random() * 0.85  // 0.55× to 1.4× — penny to dollar coin range
+    const actualRadius = radius * variance
+    const body = Bodies.circle(x, y, actualRadius, {
+        density: mat.density,
+        restitution: mat.restitution,
+        friction: 0.30,
+        frictionAir: 0.0012,
+        label: 'coin',
+    })
+    const mi = materialIdx % COIN_MATERIAL_COUNT
+    const animalIdx = Math.floor(Math.random() * COIN_ANIMAL_COUNT)
+    return { body, radius: actualRadius, colorIndex: mi, kind: 'coin', gemVariant: mi, state: 0, templateIdx: animalIdx, toughness: 1 }
+}
+
 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
     const actualRadius = radius * variance
@@ -352,6 +381,15 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         Composite.add(engine.world, sb.body)
     }
 
+    function addCoin(x: number, y: number, radius: number = CONFIG.nutBaseRadius, materialIdx?: number): void {
+        const mi = materialIdx ?? Math.floor(Math.random() * COIN_MATERIAL_COUNT)
+        const coin = spawnCoin(x, y, radius, mi)
+        Body.setAngle(coin.body, Math.random() * Math.PI * 2)
+        nuts.push(coin)
+        nutBodyMap.set(coin.body, coin)
+        Composite.add(engine.world, coin.body)
+    }
+
     function addMetalNut(x: number, y: number, nutRadius: number = CONFIG.nutBaseRadius): void {
         const colorIndex = nuts.length
         const m = spawnMetalNut(x, y, nutRadius, colorIndex)
@@ -487,6 +525,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld
         addGem,
         addBalloon,
         addStressBall,
+        addCoin,
         addMetalNut,
         addRock,
         removeAt,
diff --git a/src/renderer.ts b/src/renderer.ts
index b4d56a7..100ae70 100644
--- a/src/renderer.ts
+++ b/src/renderer.ts
@@ -461,6 +461,153 @@ function getStressBallSprite(radius: number, colorIndex: number): HTMLCanvasElem
     return sprite
 }
 
+// ── Coin palette & content ────────────────────────────────────────────────
+interface CoinMaterial { hi: string; mid: string; shadow: string; rim: string; edge: string; text: string }
+const COIN_PALETTE: CoinMaterial[] = [
+    { hi: '#FFF4A8', mid: '#D49820', shadow: '#7A5000', rim: '#B87C10', edge: '#604000', text: '#4A2E00' }, // gold
+    { hi: '#FFFFFF', mid: '#C0C8D8', shadow: '#606878', rim: '#8898A8', edge: '#405060', text: '#283848' }, // silver
+    { hi: '#FFB080', mid: '#C85428', shadow: '#6A2010', rim: '#9C3C18', edge: '#4A1408', text: '#3A0E04' }, // copper
+    { hi: '#E0B868', mid: '#8C6220', shadow: '#4A3008', rim: '#705018', edge: '#3A2008', text: '#281400' }, // bronze
+    { hi: '#F0F0E8', mid: '#A8A898', shadow: '#585850', rim: '#787870', edge: '#404038', text: '#303028' }, // nickel
+    { hi: '#EEF4FF', mid: '#9AAEC4', shadow: '#486070', rim: '#6888A4', edge: '#385060', text: '#1E3A4E' }, // platinum
+]
+
+// Value shown on heads side — one per material
+const COIN_VALUES = ['$20', '$1', '1¢', '5¢', '10¢', '$50']
+
+// Animals shown on tails side
+const COIN_ANIMALS = ['🦁', '🐘', '🦅', '🦋', '🐬', '🦊', '🐉', '🦄', '🐢', '🦎']
+
+function _drawCoinBase(ctx: CanvasRenderingContext2D, cx: number, cy: number, r: number, m: CoinMaterial, shadow: boolean): void {
+    if (shadow) {
+        ctx.save()
+        ctx.filter = `blur(${Math.ceil(r * 0.11)}px)`
+        ctx.globalAlpha = 0.42
+        ctx.fillStyle = '#000'
+        ctx.beginPath()
+        ctx.arc(cx + r * 0.09, cy + r * 0.11, r * 0.90, 0, Math.PI * 2)
+        ctx.fill()
+        ctx.restore()
+    }
+
+    // Main body
+    const grad = ctx.createRadialGradient(cx - r * 0.30, cy - r * 0.32, r * 0.03, cx, cy, r)
+    grad.addColorStop(0, m.hi)
+    grad.addColorStop(0.45, m.mid)
+    grad.addColorStop(0.88, m.shadow)
+    grad.addColorStop(1, m.edge)
+    ctx.beginPath()
+    ctx.arc(cx, cy, r, 0, Math.PI * 2)
+    ctx.fillStyle = grad
+    ctx.fill()
+
+    // Outer rim
+    ctx.lineWidth = Math.max(1.5, r * 0.05)
+    ctx.strokeStyle = m.rim
+    ctx.stroke()
+
+    // Milled edge notches
+    const notches = Math.max(16, Math.floor(r * 1.1))
+    ctx.save()
+    ctx.beginPath()
+    ctx.arc(cx, cy, r, 0, Math.PI * 2)
+    ctx.clip()
+    ctx.strokeStyle = 'rgba(0,0,0,0.18)'
+    ctx.lineWidth = Math.max(0.6, r * 0.018)
+    for (let i = 0; i < notches; i++) {
+        const a = (i / notches) * Math.PI * 2
+        const ir = r * 0.92, or = r
+        ctx.beginPath()
+        ctx.moveTo(cx + Math.cos(a) * ir, cy + Math.sin(a) * ir)
+        ctx.lineTo(cx + Math.cos(a) * or, cy + Math.sin(a) * or)
+        ctx.stroke()
+    }
+    ctx.restore()
+
+    // Inner border groove
+    ctx.beginPath()
+    ctx.arc(cx, cy, r * 0.87, 0, Math.PI * 2)
+    ctx.lineWidth = Math.max(0.7, r * 0.022)
+    ctx.strokeStyle = 'rgba(0,0,0,0.22)'
+    ctx.stroke()
+
+    // Specular highlight
+    ctx.save()
+    ctx.beginPath()
+    ctx.arc(cx, cy, r, 0, Math.PI * 2)
+    ctx.clip()
+    const hiGrad = ctx.createRadialGradient(cx - r * 0.30, cy - r * 0.34, 0, cx - r * 0.10, cy - r * 0.12, r * 0.70)
+    hiGrad.addColorStop(0, 'rgba(255,255,255,0.70)')
+    hiGrad.addColorStop(0.35, 'rgba(255,255,255,0.18)')
+    hiGrad.addColorStop(1, 'rgba(255,255,255,0)')
+    ctx.fillStyle = hiGrad
+    ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height)
+    ctx.restore()
+}
+
+function buildCoinHeadsSprite(radius: number, materialIdx: number): HTMLCanvasElement {
+    const m = COIN_PALETTE[materialIdx % COIN_PALETTE.length]
+    const value = COIN_VALUES[materialIdx % COIN_VALUES.length]
+    const pad = Math.ceil(radius * 0.28)
+    const size = Math.ceil(radius * 2 + pad * 2)
+    const oc = document.createElement('canvas')
+    oc.width = size; oc.height = size
+    const ctx = oc.getContext('2d')!
+    const cx = size / 2, cy = size / 2
+    const r = radius
+
+    _drawCoinBase(ctx, cx, cy, r, m, true)
+
+    // Value text — centred, bold
+    ctx.save()
+    ctx.beginPath()
+    ctx.arc(cx, cy, r * 0.84, 0, Math.PI * 2)
+    ctx.clip()
+    const fontSize = Math.max(8, Math.round(r * 0.52))
+    ctx.font = `bold ${fontSize}px sans-serif`
+    ctx.textAlign = 'center'
+    ctx.textBaseline = 'middle'
+    // Embossed shadow
+    ctx.fillStyle = 'rgba(0,0,0,0.30)'
+    ctx.fillText(value, cx + 1, cy + 1)
+    ctx.fillStyle = m.text
+    ctx.fillText(value, cx, cy)
+    // "LIBERTY" arc above (small)
+    if (r >= 20) {
+        const arcFontSize = Math.max(5, Math.round(r * 0.14))
+        ctx.font = `${arcFontSize}px sans-serif`
+        ctx.fillStyle = m.text
+        ctx.globalAlpha = 0.75
+        const label = 'LIBERTY'
+        const arcR = r * 0.70
+        const totalArc = Math.PI * 0.6
+        const startAngle = -Math.PI / 2 - totalArc / 2
+        for (let i = 0; i < label.length; i++) {
+            const a = startAngle + (i / (label.length - 1)) * totalArc
+            ctx.save()
+            ctx.translate(cx + Math.cos(a) * arcR, cy + Math.sin(a) * arcR)
+            ctx.rotate(a + Math.PI / 2)
+            ctx.fillText(label[i], 0, 0)
+            ctx.restore()
+        }
+    }
+    ctx.restore()
+
+    return oc
+}
+
+
+function getCoinHeadsSprite(radius: number, materialIdx: number): HTMLCanvasElement {
+    const r = roundToNearest4(radius)
+    const key = `coin_${r}_${materialIdx % COIN_PALETTE.length}`
+    let sprite = spriteCache.get(key)
+    if (!sprite) {
+        sprite = buildCoinHeadsSprite(r, materialIdx)
+        spriteCache.set(key, sprite)
+    }
+    return sprite
+}
+
 // ── Stress ball palette ───────────────────────────────────────────────────
 const STRESSBALL_COLORS = [
     { body: '#d94040', shadow: '#8a1515', hi: '#f08080' },  // red
@@ -832,6 +979,13 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer {
                 ctx.restore()
             } else if (kind === 'rock') {
                 drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx)
+            } else if (kind === 'coin') {
+                const sprite = getCoinHeadsSprite(radius, gemVariant)
+                ctx.save()
+                ctx.translate(position.x, position.y)
+                ctx.rotate(angle)
+                ctx.drawImage(sprite, -sprite.width / 2, -sprite.height / 2)
+                ctx.restore()
             } else {
                 const sprite = getSprite(radius, colorIndex, kind as 'nut' | 'gem' | 'metalnut', gemVariant)
                 ctx.save()
diff --git a/src/share.ts b/src/share.ts
index fa19f0d..c787024 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' | 'metalnut' | 'rock' | 'stressball'
+    kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
     gemVariant: number
 }
 
@@ -26,6 +26,7 @@ export function encodeNuts(nuts: NutBody[]): string {
         if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}`
         if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}`
         if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}`
+        if (n.kind === 'coin') return `r${Math.round(n.radius)}o${n.gemVariant % 6}`
         return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
     }).join(',')
 }
@@ -44,6 +45,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null {
             if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' as const, gemVariant: 0 }
             const stress = part.match(/^r(\d+)s(\d+)$/)
             if (stress) return { radius: parseInt(stress[1], 10), colorIndex: parseInt(stress[2], 10), kind: 'stressball' as const, gemVariant: parseInt(stress[2], 10) }
+            const coin = part.match(/^r(\d+)o(\d+)$/)
+            if (coin) return { radius: parseInt(coin[1], 10), colorIndex: parseInt(coin[2], 10), kind: 'coin' as const, gemVariant: parseInt(coin[2], 10) }
             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')
@@ -105,7 +108,7 @@ export function placeSharedNuts(
     nuts: SharedNut[],
     screenW: number,
     screenH: number,
-    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball', gemVariant: number) => void,
+    addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin', 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/ui.ts b/src/ui.ts
index 4c788d4..2b4d3e8 100644
--- a/src/ui.ts
+++ b/src/ui.ts
@@ -4,10 +4,10 @@ import QRCode from 'qrcode'
 import { hasVibration } from './compat'
 import { setVibrateEnabled, isVibrateEnabled, setVibrateItems, isVibrateItems, setVibrateEdges, isVibrateEdges } from './vibration'
 
-export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
+export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin'
 export type GravityMode = 'device' | 'tumbler' | 'rock'
 
-const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball']
+const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball', 'coin']
 const KIND_ICON: Record<ItemKind, string> = {
     random: '?',
     nut: '🌰',
@@ -16,6 +16,7 @@ const KIND_ICON: Record<ItemKind, string> = {
     metalnut: '🔩',
     rock: '🪨',
     stressball: '🟠',
+    coin: '🪙',
 }
 
 export interface UICallbacks {
@@ -222,6 +223,7 @@ export function initUI(callbacks: UICallbacks): void {
         { kind: 'metalnut', label: '🔩 Metal nuts' },
         { kind: 'rock', label: '🪨 Rocks' },
         { kind: 'stressball', label: '🟠 Stress balls' },
+        { kind: 'coin', label: '🪙 Coins' },
     ]
     let randomPool = new Set<ItemKind>(callbacks.initialRandomPool)