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