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