nuts / commit
added rocks that break
src/main.ts | 33 ++++++---- src/persistence.ts | 5 +- src/physics.ts | 176 ++++++++++++++++++++++++++++++++++++++++++++++------- src/renderer.ts | 107 +++++++++++++++++++++++++++++++- src/share.ts | 11 +++- src/style.css | 4 ++ src/ui.ts | 9 ++- 7 files changed, 303 insertions(+), 42 deletions(-) diff --git a/src/main.ts b/src/main.ts index de651c7..33c294c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -55,8 +55,9 @@ async function main(): Promise<void> { let tapsLocked = false let gravityMode: GravityMode = 'device' let vibrateEnabled = true + let currentKind: ItemKind = 'random' - // ── Restore persisted settings ──────────────────────────────────────────── + // ── Restore persisted settings ──────────────────────────────────────── const savedSettings = loadSettings() if (savedSettings) { currentNutRadius = savedSettings.nutRadius @@ -67,6 +68,10 @@ async function main(): Promise<void> { vibrateEnabled = false setVibrateEnabled(false) } + const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock'] + if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) { + currentKind = savedSettings.kind as ItemKind + } } // ── Restore nut positions (share URL takes priority over snapshot) ──────── @@ -90,6 +95,7 @@ async function main(): Promise<void> { 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 if (kind === 'rock') physics.addRock(x, y, r) else physics.addNut(x, y, r) }, initialHasSpace, @@ -103,6 +109,7 @@ async function main(): Promise<void> { 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 if (n.kind === 'rock') physics.addRock(x, y, n.radius) else physics.addNut(x, y, n.radius) } } @@ -114,9 +121,10 @@ async function main(): Promise<void> { initialLocked: tapsLocked, initialGravityMode: gravityMode, initialVibrateEnabled: vibrateEnabled, + initialKind: currentKind, onSizeChange: (radius) => { currentNutRadius = radius - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) }, onClear: () => { physics.clearNuts() @@ -124,22 +132,24 @@ async function main(): Promise<void> { }, onLockChange: (locked) => { tapsLocked = locked - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) + }, + onKindChange: (kind) => { + currentKind = kind + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) }, - onKindChange: (kind) => { currentKind = kind }, onGravityModeChange: (mode) => { gravityMode = mode - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) }, onVibrateChange: (enabled) => { vibrateEnabled = enabled - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) }, getShareURL: () => buildShareURL(physics.nuts, currentNutRadius), }) // ── Helpers ─────────────────────────────────────────────────────────────── - let currentKind: ItemKind = 'random' function hasSpace(x: number, y: number, r: number): boolean { for (const { body, radius } of physics.nuts) { @@ -157,11 +167,12 @@ async function main(): Promise<void> { let kind = currentKind if (kind === 'random') { const rand = Math.random() - kind = rand < 0.20 ? 'balloon' : rand < 0.40 ? 'gem' : rand < 0.55 ? 'metalnut' : 'nut' + kind = rand < 0.20 ? 'balloon' : rand < 0.38 ? 'gem' : rand < 0.50 ? 'metalnut' : rand < 0.65 ? 'rock' : '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 if (kind === 'rock') physics.addRock(cx, cy, currentNutRadius) else physics.addNut(cx, cy, currentNutRadius) } } @@ -301,8 +312,8 @@ async function main(): Promise<void> { // ── Auto-gravity modes ─────────────────────────────────────────────── if (gravityMode === 'tumbler') { - // Full 360° rotation at ~18 seconds per revolution - const angle = (timestamp / 1000) * Math.PI * 2 + // Full 360° rotation at 0.1 Hz (10 seconds per revolution) + const angle = (timestamp / 1000) * 0.1 * Math.PI * 2 physics.setGravity(Math.sin(angle), Math.cos(angle)) } else if (gravityMode === 'rock') { // Oscillate ±45° around landscape-down (gravity pointing right in portrait) @@ -327,7 +338,7 @@ async function main(): Promise<void> { // Also save on tab close / navigation window.addEventListener('pagehide', () => { saveSnapshot(physics.nuts, cssWidth, cssHeight) - saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled }) + saveSettings({ nutRadius: currentNutRadius, gravityMode, vibrateEnabled, kind: currentKind }) }) } diff --git a/src/persistence.ts b/src/persistence.ts index 24edf50..c560eba 100644 --- a/src/persistence.ts +++ b/src/persistence.ts @@ -12,6 +12,7 @@ export interface PersistedSettings { nutRadius: number gravityMode?: string vibrateEnabled?: boolean + kind?: string } export function loadSettings(): PersistedSettings | null { @@ -40,7 +41,7 @@ export interface NutSnapshot { angle: number radius: number // absolute px colorIndex: number - kind: 'nut' | 'gem' | 'balloon' | 'metalnut' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' gemVariant: number } @@ -52,7 +53,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'; gemVariant: number }>, + nuts: Array<{ body: { position: { x: number; y: number }; angle: number }; radius: number; colorIndex: number; kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock'; gemVariant: number }>, screenW: number, screenH: number, ): void { diff --git a/src/physics.ts b/src/physics.ts index a5457b0..b3fb85b 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -11,8 +11,9 @@ export interface NutBody { body: Matter.Body radius: number colorIndex: number - kind: 'nut' | 'gem' | 'balloon' | 'metalnut' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex + hitCount: number // rocks: number of qualifying impacts received } export interface PhysicsWorld { @@ -22,6 +23,7 @@ export interface PhysicsWorld { 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 + addRock: (x: number, y: number, radius?: number) => void removeAt: (x: number, y: number) => boolean clearNuts: () => void resize: (width: number, height: number) => void @@ -64,7 +66,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): frictionAir: CONFIG.frictionAir, label: 'nut', }) - return { body, radius, colorIndex, kind: 'nut', gemVariant: 0 } + return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0 } } function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { @@ -78,7 +80,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb frictionAir: 0.0008, label: 'metalnut', }) - return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0 } + return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0 } } const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant @@ -97,7 +99,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 } + return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, hitCount: 0 } } function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): NutBody { @@ -110,11 +112,39 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): frictionAir: 0.008, label: 'balloon', }) - return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex } + return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0 } } // ── Factory ──────────────────────────────────────────────────────────────── +const ROCK_COLORS_COUNT = 4 +const ROCK_SPLIT_SPEED = 6 // min px/tick to count as a hit +const ROCK_HITS_TO_SPLIT = 10 // hits required to break + +function spawnRock(x: number, y: number, targetRadius: number, colorIndex: number): NutBody { + const variance = 0.85 + Math.random() * 0.3 + const radius = targetRadius * variance + const sides = 5 + Math.floor(Math.random() * 4) // 5–8 sides + + // Irregular convex polygon: evenly-spaced base angles + small jitter + const verts: Matter.Vector[] = [] + for (let i = 0; i < sides; i++) { + const baseAngle = (i / sides) * Math.PI * 2 + const jitter = (Math.random() - 0.5) * (Math.PI / sides) * 0.85 + const r = radius * (0.6 + Math.random() * 0.65) + verts.push(Vector.create(Math.cos(baseAngle + jitter) * r, Math.sin(baseAngle + jitter) * r)) + } + + const body = Bodies.fromVertices(x, y, [verts], { + density: 0.0025, // granite-like: ~2.5× default + restitution: 0.25, // rocks don't bounce much + friction: 0.65, + frictionAir: 0.001, + label: 'rock', + }) + return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0 } +} + export function createPhysicsWorld(width: number, height: number): PhysicsWorld { const engine = Engine.create() engine.gravity.x = 0 @@ -124,6 +154,12 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld let nuts: NutBody[] = [] let currentWidth = width let currentHeight = height + let splitQueue: NutBody[] = [] + const splittingIds = new Set<number>() + let targetRockMass = 0 + let rockAddCount = 0 + let rockAddRadiusSum = 0 + let refillRetryAt = 0 // engine timestamp after which to try again Composite.add(engine.world, walls) @@ -147,45 +183,66 @@ 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' || pair.bodyA.label === 'metalnut' - const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' + const aNut = pair.bodyA.label === 'nut' || pair.bodyA.label === 'gem' || pair.bodyA.label === 'balloon' || pair.bodyA.label === 'metalnut' || pair.bodyA.label === 'rock' + const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut' const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut' + const aRock = pair.bodyA.label === 'rock' + const bRock = pair.bodyB.label === 'rock' const gemInvolved = aGem || bGem + // Queue rock splits before vibration so we have speed info + const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) + if (speed >= ROCK_SPLIT_SPEED) { + const tryQueueSplit = (b: Matter.Body) => { + if (b.label !== 'rock') return + if (splittingIds.has(b.id)) return + const nb = nuts.find(n => n.body === b) + if (!nb) return + nb.hitCount++ + if (nb.hitCount < ROCK_HITS_TO_SPLIT) return + splittingIds.add(b.id) + splitQueue.push(nb) + } + if (aRock) tryQueueSplit(pair.bodyA) + if (bRock) tryQueueSplit(pair.bodyB) + } + if (aNut && bNut) { - // Nut/gem-nut/gem collision - const speed = Math.max(pair.bodyA.speed, pair.bodyB.speed) + // Body-body collision dbg('nut-nut speed:', speed.toFixed(2)) if (speed > CONFIG.impactThreshold) { - if (aGem && bGem) { - // gem/metal-gem/metal: sharp short clack + if (aRock || bRock) { + // rock collision: earthy thud + const t = Math.min((speed - CONFIG.impactThreshold) / 10, 1) + vibrate(Math.round(12 + t * 28)) + } else if (aGem && bGem) { 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) { - // body-wall collision + // Body-wall collision const nutBody = aWall ? pair.bodyB : pair.bodyA - 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) { - if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { - // gem/metal-wall: sharp hard clack - const t = Math.min((speed - CONFIG.impactThreshold) / 8, 1) + if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock') continue + const wallSpeed = nutBody.speed + dbg('wall hit speed:', wallSpeed.toFixed(2)) + if (wallSpeed > CONFIG.impactThreshold) { + if (nutBody.label === 'rock') { + const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 10, 1) + vibrate(Math.round(15 + t * 30)) + } else if (nutBody.label === 'gem' || nutBody.label === 'metalnut') { + const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 8, 1) vibrate(Math.round(15 + t * 35)) } else { - const t = Math.min((speed - CONFIG.impactThreshold) / 12, 1) + const t = Math.min((wallSpeed - CONFIG.impactThreshold) / 12, 1) vibrate(Math.round(30 + t * (CONFIG.maxVibrationMs - 30))) } } @@ -226,6 +283,78 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld Composite.add(engine.world, m.body) } + function addRock(x: number, y: number, radius: number = CONFIG.nutBaseRadius): void { + const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT) + const rock = spawnRock(x, y, radius, colorIndex) + Body.setAngle(rock.body, Math.random() * Math.PI * 2) + nuts.push(rock) + Composite.add(engine.world, rock.body) + targetRockMass += rock.body.mass + rockAddRadiusSum += radius + rockAddCount++ + } + + function refillRocks(): void { + if (targetRockMass === 0) return + const now = engine.timing.timestamp + if (now < refillRetryAt) return + const currentMass = nuts.filter(n => n.kind === 'rock').reduce((s, n) => s + n.body.mass, 0) + const avgRadius = rockAddRadiusSum / rockAddCount + const bigRockRefMass = Math.PI * avgRadius * avgRadius * 0.0025 + if (currentMass >= targetRockMass - bigRockRefMass) return + + const spawnRadius = avgRadius + const clearance = spawnRadius * 2.2 // minimum gap around spawn point + const x = currentWidth / 2 + (Math.random() - 0.5) * currentWidth * 0.1 + const y = currentHeight / 2 + (Math.random() - 0.5) * currentHeight * 0.1 + const blocked = nuts.some(n => { + const dx = n.body.position.x - x + const dy = n.body.position.y - y + return Math.sqrt(dx * dx + dy * dy) < clearance + n.radius + }) + if (blocked) { + refillRetryAt = now + 1500 // retry in 1.5 s + return + } + + const colorIndex = Math.floor(Math.random() * ROCK_COLORS_COUNT) + const rock = spawnRock(x, y, spawnRadius, colorIndex) + Body.setAngle(rock.body, Math.random() * Math.PI * 2) + nuts.push(rock) + Composite.add(engine.world, rock.body) + } + + function processSplits(): void { + if (splitQueue.length === 0) return + const toSplit = splitQueue.splice(0) + for (const nut of toSplit) { + splittingIds.delete(nut.body.id) + const idx = nuts.indexOf(nut) + if (idx === -1) continue + const { position, velocity } = nut.body + Composite.remove(engine.world, nut.body) + nuts.splice(idx, 1) + + if (nut.radius <= CONFIG.nutMinRadius * 1.4) continue + + const count = nut.radius > CONFIG.nutMinRadius * 3 ? 3 : 2 + const newRadius = (nut.radius / Math.sqrt(count)) * 0.9 + for (let i = 0; i < count; i++) { + const spreadAngle = (i / count) * Math.PI * 2 + Math.random() * 0.8 + const dist = nut.radius * 0.3 + const fx = position.x + Math.cos(spreadAngle) * dist + const fy = position.y + Math.sin(spreadAngle) * dist + const fragment = spawnRock(fx, fy, newRadius, nut.colorIndex) + Body.setVelocity(fragment.body, { + x: velocity.x + Math.cos(spreadAngle) * 3.5, + y: velocity.y + Math.sin(spreadAngle) * 3.5, + }) + nuts.push(fragment) + Composite.add(engine.world, fragment.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--) { @@ -271,6 +400,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld function step(deltaMs: number): void { Engine.update(engine, Math.min(deltaMs, 50)) + processSplits() + refillRocks() } function destroy(): void { @@ -285,6 +416,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld addGem, addBalloon, addMetalNut, + addRock, removeAt, clearNuts, resize, diff --git a/src/renderer.ts b/src/renderer.ts index 041837c..aa2cb80 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,4 +1,5 @@ import type { NutBody } from './physics' +import Matter from 'matter-js' import { CONFIG } from './config' import QRCode from 'qrcode' @@ -321,6 +322,108 @@ function buildMetalSprite(radius: number, colorIndex: number): HTMLCanvasElement return oc } +// ── Rock (drawn directly from world-space body vertices) ─────────────────── + +const ROCK_PALETTE = [ + { fill: '#7A5A38', hi: '#A07A52', dark: '#4A3218', stroke: '#2E1C08' }, + { fill: '#6A5028', hi: '#8E7045', dark: '#3C2A0E', stroke: '#221608' }, + { fill: '#887060', hi: '#A89080', dark: '#604840', stroke: '#3A2820' }, + { fill: '#705540', hi: '#987860', dark: '#483020', stroke: '#2C1810' }, +] + +function drawRock(ctx: CanvasRenderingContext2D, body: Matter.Body, radius: number, colorIndex: number, hitCount: number): void { + const c = ROCK_PALETTE[colorIndex % ROCK_PALETTE.length] + const verts = body.vertices + const cx = body.position.x + const cy = body.position.y + const blurR = Math.ceil(radius * 0.15) + + function rockPath(ox = 0, oy = 0) { + ctx.beginPath() + ctx.moveTo(verts[0].x + ox, verts[0].y + oy) + for (let i = 1; i < verts.length; i++) ctx.lineTo(verts[i].x + ox, verts[i].y + oy) + ctx.closePath() + } + + // Drop shadow + ctx.save() + ctx.filter = `blur(${blurR}px)` + ctx.globalAlpha = 0.35 + ctx.fillStyle = '#000' + rockPath(radius * 0.07, radius * 0.1) + ctx.fill() + ctx.restore() + + // Base metallic-earthy radial gradient + const grad = ctx.createRadialGradient( + cx - radius * 0.2, cy - radius * 0.25, radius * 0.05, + cx, cy, radius * 0.95 + ) + grad.addColorStop(0, c.hi) + grad.addColorStop(0.5, c.fill) + grad.addColorStop(1, c.dark) + rockPath() + ctx.fillStyle = grad + ctx.fill() + + // Rim stroke + ctx.lineWidth = Math.max(1.5, radius * 0.05) + ctx.strokeStyle = c.stroke + ctx.stroke() + + // Specular highlight clipped to rock shape + ctx.save() + rockPath() + ctx.clip() + ctx.beginPath() + ctx.ellipse(cx - radius * 0.2, cy - radius * 0.22, radius * 0.3, radius * 0.18, -0.5, 0, Math.PI * 2) + ctx.fillStyle = 'rgba(255,255,255,0.14)' + ctx.fill() + ctx.restore() + + // Cracks — stable per body ID, grow with hit progress + const HITS_TO_SPLIT = 10 + const progress = hitCount / HITS_TO_SPLIT + if (progress >= 0.1) { + let s = (body.id * 2654435761) >>> 0 + const rng = () => { s = (Math.imul(s, 1664525) + 1013904223) >>> 0; return s / 0x100000000 } + const numCracks = Math.min(Math.floor(progress * 5) + 1, 5) + ctx.save() + rockPath() + ctx.clip() + ctx.lineCap = 'round' + ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress)) + for (let i = 0; i < numCracks; i++) { + const startAngle = rng() * Math.PI * 2 + const startDist = rng() * radius * 0.25 + const crackAngle = rng() * Math.PI * 2 + const crackLen = radius * (0.35 + rng() * 0.45) * (0.4 + progress * 0.6) + const segs = 2 + Math.floor(rng() * 2) + const sx = cx + Math.cos(startAngle) * startDist + const sy = cy + Math.sin(startAngle) * startDist + // dark inner crack + ctx.strokeStyle = `rgba(0,0,0,${0.55 + progress * 0.3})` + ctx.beginPath() + ctx.moveTo(sx, sy) + for (let j = 1; j <= segs; j++) { + const t = j / segs + const jitter = (rng() - 0.5) * radius * 0.18 + ctx.lineTo( + sx + Math.cos(crackAngle) * crackLen * t + Math.cos(crackAngle + Math.PI / 2) * jitter, + sy + Math.sin(crackAngle) * crackLen * t + Math.sin(crackAngle + Math.PI / 2) * jitter + ) + } + ctx.stroke() + // thin light edge to give depth + ctx.strokeStyle = `rgba(255,255,255,${0.12 + progress * 0.08})` + ctx.lineWidth = Math.max(0.4, radius * 0.012) + ctx.stroke() + ctx.lineWidth = Math.max(0.6, radius * 0.03 * (0.5 + progress)) + } + ctx.restore() + } +} + function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'metalnut', gemVariant: number): HTMLCanvasElement { const r = roundToNearest4(radius) const key = kind === 'gem' @@ -476,7 +579,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.fillRect(0, 0, w, h) // Nuts, gems & balloons - for (const { body, radius, colorIndex, kind, gemVariant } of nuts) { + for (const { body, radius, colorIndex, kind, gemVariant, hitCount } of nuts) { const { position, angle } = body if (kind === 'balloon') { @@ -485,6 +588,8 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.rotate(gravityAngle) drawBalloon(ctx, 0, 0, radius, colorIndex) ctx.restore() + } else if (kind === 'rock') { + drawRock(ctx, body, radius, colorIndex, hitCount) } 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 da51cb8..5138bf4 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' + kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' gemVariant: number } @@ -24,6 +24,7 @@ export function encodeNuts(nuts: NutBody[]): string { 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}` + if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}` return `r${Math.round(n.radius)}c${n.colorIndex % 5}` }).join(',') } @@ -38,6 +39,8 @@ export function decodeNuts(encoded: string): SharedNut[] | null { 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 rock = part.match(/^r(\d+)k(\d+)$/) + if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' 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') @@ -66,7 +69,9 @@ export function buildShareURL(nuts: NutBody[], nutRadius: number): string { ? `r${Math.round(n.radius)}b${n.colorIndex % 7}` : n.kind === 'metalnut' ? `r${Math.round(n.radius)}m${n.colorIndex % 5}` - : `r${Math.round(n.radius)}c${n.colorIndex % 5}` + : n.kind === 'rock' + ? `r${Math.round(n.radius)}k${n.colorIndex % 4}` + : `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) @@ -105,7 +110,7 @@ export function placeSharedNuts( nuts: SharedNut[], screenW: number, screenH: number, - addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut', gemVariant: number) => void, + addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock', 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 99f056e..b5ba3ef 100644 --- a/src/style.css +++ b/src/style.css @@ -167,6 +167,10 @@ body { color: #C0C8D8; } +.toolbar-btn.kind-rock { + color: #9A7858; +} + .toolbar-btn.kind-random { color: #aaa; } diff --git a/src/ui.ts b/src/ui.ts index f2c7020..ad40100 100644 --- a/src/ui.ts +++ b/src/ui.ts @@ -4,16 +4,17 @@ import QRCode from 'qrcode' import { hasVibration } from './compat' import { setVibrateEnabled, isVibrateEnabled } from './vibration' -export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' +export type ItemKind = 'random' | 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' export type GravityMode = 'device' | 'tumbler' | 'rock' -const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut'] +const KIND_SEQUENCE: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock'] const KIND_ICON: Record<ItemKind, string> = { random: '?', nut: '🌰', gem: '💎', balloon: '🎈', metalnut: '🔩', + rock: '🪨', } export interface UICallbacks { @@ -21,6 +22,7 @@ export interface UICallbacks { initialLocked: boolean initialGravityMode: GravityMode initialVibrateEnabled: boolean + initialKind: ItemKind onSizeChange: (radius: number) => void onClear: () => void onLockChange: (locked: boolean) => void @@ -82,7 +84,8 @@ export function initUI(callbacks: UICallbacks): void { }) // ── Kind toggle ────────────────────────────────────────────────────────── - let kindIndex = 0 // starts at 'random' + let kindIndex = KIND_SEQUENCE.indexOf(callbacks.initialKind) + if (kindIndex === -1) kindIndex = 0 kindBtn.textContent = KIND_ICON[KIND_SEQUENCE[kindIndex]] kindBtn.className = `toolbar-btn kind-${KIND_SEQUENCE[kindIndex]}`