nuts / commit
bug fixes
src/main.ts | 6 +-- src/physics.ts | 76 +++++++++++++++++---------------- src/renderer.ts | 130 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-- src/share.ts | 16 ++----- 4 files changed, 170 insertions(+), 58 deletions(-) diff --git a/src/main.ts b/src/main.ts index 58503c3..3efbf4f 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'] + const DEFAULT_RANDOM_POOL: ItemKind[] = ['nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] 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'] + const validKinds: ItemKind[] = ['random', 'nut', 'gem', 'balloon', 'metalnut', 'rock', 'stressball'] if (savedSettings.kind && (validKinds as string[]).includes(savedSettings.kind)) { currentKind = savedSettings.kind as ItemKind } @@ -263,7 +263,6 @@ async function main(): Promise<void> { if (motionEnabled) return motionEnabled = true dbg('startMotion, vibrate?', 'vibrate' in navigator, 'protocol:', location.protocol) - let lastGx = 0, lastGy = 0 initMotion({ onShake: (intensity) => { physics.applyShakeForce(intensity) @@ -271,7 +270,6 @@ async function main(): Promise<void> { onOrientation: (gx, gy) => { if (gravityMode === 'device') { physics.setGravity(gx, gy) - lastGx = gx; lastGy = gy } }, }) diff --git a/src/physics.ts b/src/physics.ts index e4beb25..25841cf 100644 --- a/src/physics.ts +++ b/src/physics.ts @@ -6,6 +6,9 @@ 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) + // ── Types ────────────────────────────────────────────────────────────────── export interface NutBody { @@ -13,8 +16,8 @@ export interface NutBody { radius: number colorIndex: number kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' - gemVariant: number // gems: 0=diamond…4=amethyst balloons: colorIndex - hitCount: number // rocks: accumulated damage + 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 toughness: number // rocks: damage multiplier (0.5=brittle … 2=hard); 1 for non-rocks } @@ -70,7 +73,7 @@ function spawnNut(x: number, y: number, nutRadius: number, colorIndex: number): frictionAir: CONFIG.frictionAir, label: 'nut', }) - return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 } + return { body, radius, colorIndex, kind: 'nut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } } function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: number): NutBody { @@ -84,7 +87,7 @@ function spawnMetalNut(x: number, y: number, nutRadius: number, colorIndex: numb frictionAir: 0.0008, label: 'metalnut', }) - return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, hitCount: 0, templateIdx: 0, toughness: 1 } + return { body, radius, colorIndex, kind: 'metalnut', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } } const GEM_SIDES = [4, 6, 6, 8, 6] as const // sides per gem variant @@ -92,15 +95,6 @@ export const GEM_VARIANT_COUNT = GEM_SIDES.length export const BALLOON_COLOR_COUNT = 7 export const STRESSBALL_COLOR_COUNT = 6 -const STRESSBALL_COLORS = [ - '#e05050', // red - '#e07830', // orange - '#d4c020', // yellow - '#50b050', // green - '#4090d0', // blue - '#9060c0', // purple -] - function spawnGem(x: number, y: number, radius: number, gemVariant: number): NutBody { const variance = 1 + (Math.random() * 2 - 1) * CONFIG.nutRadiusVariance const actualRadius = radius * variance @@ -113,7 +107,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, hitCount: 0, templateIdx: 0, toughness: 1 } + return { body, radius: actualRadius, colorIndex: 0, kind: 'gem', gemVariant, state: 0, templateIdx: 0, toughness: 1 } } const BALLOON_POP_SPEED = 18 // speed (px/tick) that instantly pops a balloon @@ -129,7 +123,7 @@ function spawnBalloon(x: number, y: number, radius: number, colorIndex: number): frictionAir: 0.006, label: 'balloon', }) - return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 } + return { body, radius: actualRadius, colorIndex, kind: 'balloon', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } } const STRESSBALL_SQUISH_DECAY = 0.80 // faster recovery than balloon @@ -144,7 +138,7 @@ function spawnStressBall(x: number, y: number, radius: number, colorIndex: numbe frictionAir: 0.015, label: 'stressball', }) - return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: colorIndex, hitCount: 0, templateIdx: 0, toughness: 1 } + return { body, radius: actualRadius, colorIndex: colorIndex % STRESSBALL_COLOR_COUNT, kind: 'stressball', gemVariant: 0, state: 0, templateIdx: 0, toughness: 1 } } // ── Factory ──────────────────────────────────────────────────────────────── @@ -169,7 +163,7 @@ function spawnRock(x: number, y: number, targetRadius: number, colorIndex: numbe }) // Toughness: log-uniform from 0.5 (brittle) to 2.0 (hard) const toughness = Math.pow(2, (Math.random() - 0.5) * 2) // 2^[-1, 1] = 0.5 … 2.0 - return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, hitCount: 0, templateIdx, toughness } + return { body, radius, colorIndex: colorIndex % ROCK_COLORS_COUNT, kind: 'rock', gemVariant: 0, state: 0, templateIdx, toughness } } export function createPhysicsWorld(width: number, height: number): PhysicsWorld { @@ -179,8 +173,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld let walls = buildWalls(width, height) let nuts: NutBody[] = [] - let currentWidth = width - let currentHeight = height + const nutBodyMap = new Map<Matter.Body, NutBody>() let splitQueue: NutBody[] = [] const splittingIds = new Set<number>() let popQueue: NutBody[] = [] @@ -195,9 +188,9 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld for (const nb of nuts) { if (nb.kind === 'balloon') { // Decay squish (hitCount) toward zero each tick - if (nb.hitCount > 0) { - nb.hitCount *= BALLOON_SQUISH_DECAY - if (nb.hitCount < 0.005) nb.hitCount = 0 + if (nb.state > 0) { + nb.state *= BALLOON_SQUISH_DECAY + if (nb.state < 0.005) nb.state = 0 } // Buoyancy Body.applyForce(nb.body, nb.body.position, { @@ -205,9 +198,9 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld y: -nb.body.mass * engine.gravity.y * scale, }) } else if (nb.kind === 'stressball') { - if (nb.hitCount > 0) { - nb.hitCount *= STRESSBALL_SQUISH_DECAY - if (nb.hitCount < 0.005) nb.hitCount = 0 + if (nb.state > 0) { + nb.state *= STRESSBALL_SQUISH_DECAY + if (nb.state < 0.005) nb.state = 0 } } } @@ -218,8 +211,8 @@ 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' || pair.bodyA.label === 'rock' || pair.bodyA.label === 'stressball' - const bNut = pair.bodyB.label === 'nut' || pair.bodyB.label === 'gem' || pair.bodyB.label === 'balloon' || pair.bodyB.label === 'metalnut' || pair.bodyB.label === 'rock' || pair.bodyB.label === 'stressball' + const aNut = ITEM_LABELS.has(pair.bodyA.label as never) + const bNut = ITEM_LABELS.has(pair.bodyB.label as never) const aGem = pair.bodyA.label === 'gem' || pair.bodyA.label === 'metalnut' const bGem = pair.bodyB.label === 'gem' || pair.bodyB.label === 'metalnut' @@ -239,8 +232,8 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld // Normalised so 10 hits at ROCK_SPLIT_SPEED*1.5 from equal mass ≈ 10. const excess = speed - ROCK_SPLIT_SPEED const massRatio = otherBody.mass / rockBody.mass - nb.hitCount += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness) - if (nb.hitCount < ROCK_HITS_TO_SPLIT) return + nb.state += (excess * excess * massRatio) / (ROCK_SPLIT_SPEED * ROCK_SPLIT_SPEED * 1.25 * nb.toughness) + if (nb.state < ROCK_HITS_TO_SPLIT) return splittingIds.add(rockBody.id) splitQueue.push(nb) } @@ -256,12 +249,12 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld if (aBalloon || bBalloon) { const applyPressure = (balloonBody: Matter.Body) => { if (poppingIds.has(balloonBody.id)) return - const nb = nuts.find(n => n.body === balloonBody) + const nb = nutBodyMap.get(balloonBody) if (!nb) return // Pressure scales as speed^1.5 so gentle bounces squish, hard hits pop const pressure = Math.pow(speed / BALLOON_POP_SPEED, 1.5) - nb.hitCount = Math.min(nb.hitCount + pressure, 1.5) - if (nb.hitCount >= 1.0) { + nb.state = Math.min(nb.state + pressure, 1.5) + if (nb.state >= 1.0) { poppingIds.add(balloonBody.id) popQueue.push(nb) } @@ -273,10 +266,10 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld // ── Stress ball squish (no pop) ───────────────────────────── if (aStress || bStress) { const applySquish = (sbBody: Matter.Body) => { - const nb = nuts.find(n => n.body === sbBody) + const nb = nutBodyMap.get(sbBody) if (!nb) return const pressure = Math.pow(speed / 14, 1.2) - nb.hitCount = Math.min(nb.hitCount + pressure, 1.0) + nb.state = Math.min(nb.state + pressure, 1.0) } if (aStress) applySquish(pair.bodyA) if (bStress) applySquish(pair.bodyB) @@ -304,7 +297,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld } else if (aNut || bNut) { // Body-wall collision const nutBody = aWall ? pair.bodyB : pair.bodyA - if (nutBody.label !== 'nut' && nutBody.label !== 'gem' && nutBody.label !== 'balloon' && nutBody.label !== 'metalnut' && nutBody.label !== 'rock' && nutBody.label !== 'stressball') continue + if (!ITEM_LABELS.has(nutBody.label as never)) continue const wallSpeed = nutBody.speed dbg('wall hit speed:', wallSpeed.toFixed(2)) if (wallSpeed > CONFIG.impactThreshold) { @@ -330,6 +323,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const nut = spawnNut(x, y, nutRadius, colorIndex) Body.setAngle(nut.body, Math.random() * Math.PI * 2) nuts.push(nut) + nutBodyMap.set(nut.body, nut) Composite.add(engine.world, nut.body) } @@ -338,6 +332,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const gem = spawnGem(x, y, radius, variant) Body.setAngle(gem.body, Math.random() * Math.PI * 2) nuts.push(gem) + nutBodyMap.set(gem.body, gem) Composite.add(engine.world, gem.body) } @@ -345,6 +340,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const ci = colorIndex ?? Math.floor(Math.random() * BALLOON_COLOR_COUNT) const balloon = spawnBalloon(x, y, radius, ci) nuts.push(balloon) + nutBodyMap.set(balloon.body, balloon) Composite.add(engine.world, balloon.body) } @@ -352,6 +348,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const ci = colorIndex ?? Math.floor(Math.random() * STRESSBALL_COLOR_COUNT) const sb = spawnStressBall(x, y, radius, ci) nuts.push(sb) + nutBodyMap.set(sb.body, sb) Composite.add(engine.world, sb.body) } @@ -360,6 +357,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const m = spawnMetalNut(x, y, nutRadius, colorIndex) Body.setAngle(m.body, Math.random() * Math.PI * 2) nuts.push(m) + nutBodyMap.set(m.body, m) Composite.add(engine.world, m.body) } @@ -368,6 +366,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const rock = spawnRock(x, y, radius, colorIndex) Body.setAngle(rock.body, Math.random() * Math.PI * 2) nuts.push(rock) + nutBodyMap.set(rock.body, rock) Composite.add(engine.world, rock.body) } @@ -378,6 +377,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld poppingIds.delete(nut.body.id) const idx = nuts.indexOf(nut) if (idx === -1) continue + nutBodyMap.delete(nut.body) Composite.remove(engine.world, nut.body) nuts.splice(idx, 1) vibrate(70) // satisfying pop @@ -395,6 +395,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const pos = { x: nut.body.position.x, y: nut.body.position.y } const vel = { x: nut.body.velocity.x, y: nut.body.velocity.y } + nutBodyMap.delete(nut.body) Composite.remove(engine.world, nut.body) nuts.splice(idx, 1) @@ -419,6 +420,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld y: vel.y + Math.sin(spreadAngle) * (2 + Math.random() * 2), }) nuts.push(frag) + nutBodyMap.set(frag.body, frag) Composite.add(engine.world, frag.body) } } @@ -431,6 +433,7 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld const dx = body.position.x - x const dy = body.position.y - y if (dx * dx + dy * dy <= radius * radius) { + nutBodyMap.delete(body) Composite.remove(engine.world, body) nuts.splice(i, 1) return true @@ -441,12 +444,11 @@ export function createPhysicsWorld(width: number, height: number): PhysicsWorld function clearNuts(): void { for (const { body } of nuts) Composite.remove(engine.world, body) + nutBodyMap.clear() nuts = [] } function resize(newWidth: number, newHeight: number): void { - currentWidth = newWidth - currentHeight = newHeight for (const wall of walls) Composite.remove(engine.world, wall) walls = buildWalls(newWidth, newHeight) Composite.add(engine.world, walls) diff --git a/src/renderer.ts b/src/renderer.ts index d4a8e27..b4d56a7 100644 --- a/src/renderer.ts +++ b/src/renderer.ts @@ -1,5 +1,5 @@ import type { NutBody } from './physics' -import Matter from 'matter-js' +import type Matter from 'matter-js' import { CONFIG } from './config' import QRCode from 'qrcode' import { ROCK_TEMPLATES } from './rockTemplates' @@ -439,6 +439,28 @@ function getSprite(radius: number, colorIndex: number, kind: 'nut' | 'gem' | 'me return sprite } +function getBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement { + const r = roundToNearest4(radius) + const key = `balloon_${r}_${colorIndex % BALLOON_COLORS.length}` + let sprite = spriteCache.get(key) + if (!sprite) { + sprite = buildBalloonBodySprite(r, colorIndex) + spriteCache.set(key, sprite) + } + return sprite +} + +function getStressBallSprite(radius: number, colorIndex: number): HTMLCanvasElement { + const r = roundToNearest4(radius) + const key = `stressball_${r}_${colorIndex % STRESSBALL_COLORS.length}` + let sprite = spriteCache.get(key) + if (!sprite) { + sprite = buildStressBallBodySprite(r, colorIndex) + spriteCache.set(key, sprite) + } + return sprite +} + // ── Stress ball palette ─────────────────────────────────────────────────── const STRESSBALL_COLORS = [ { body: '#d94040', shadow: '#8a1515', hi: '#f08080' }, // red @@ -449,6 +471,17 @@ const STRESSBALL_COLORS = [ { body: '#8850c0', shadow: '#441878', hi: '#c094e8' }, // purple ] +function buildStressBallBodySprite(radius: number, colorIndex: number): HTMLCanvasElement { + const r = radius + const pad = Math.ceil(r * 0.26) + const size = Math.ceil(r * 2 + pad * 2) + const oc = document.createElement('canvas') + oc.width = size + oc.height = size + drawStressBall(oc.getContext('2d')!, size / 2, size / 2, r, colorIndex, 0) + return oc +} + function drawStressBall( ctx: CanvasRenderingContext2D, x: number, y: number, @@ -551,6 +584,52 @@ const BALLOON_COLORS: BalloonColor[] = [ { body: '#22DDEE', hi: '#88EEFF', shadow: '#117788', rim: '#11CCDD', string: '#0099AA' }, // cyan ] +function buildBalloonBodySprite(radius: number, colorIndex: number): HTMLCanvasElement { + const c = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length] + const r = radius + const pad = Math.ceil(r * 0.28) + const size = Math.ceil(r * 2 + pad * 2) + const oc = document.createElement('canvas') + oc.width = size + oc.height = size + const bctx = oc.getContext('2d')! + const cx = size / 2, cy = size / 2 + // Shadow (blur done once, cached) + bctx.save() + bctx.filter = `blur(${Math.ceil(r * 0.14)}px)` + bctx.globalAlpha = 0.32 + bctx.fillStyle = '#000' + bctx.beginPath() + bctx.arc(cx + r * 0.12, cy + r * 0.15, r * 0.88, 0, Math.PI * 2) + bctx.fill() + bctx.restore() + // Body + const grad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.35, r * 0.04, cx, cy, r) + grad.addColorStop(0, c.hi) + grad.addColorStop(0.55, c.body) + grad.addColorStop(1, c.shadow) + bctx.beginPath() + bctx.arc(cx, cy, r, 0, Math.PI * 2) + bctx.fillStyle = grad + bctx.fill() + bctx.strokeStyle = c.rim + bctx.lineWidth = Math.max(1.5, r * 0.03) + bctx.stroke() + // Specular highlight + bctx.save() + bctx.beginPath() + bctx.arc(cx, cy, r, 0, Math.PI * 2) + bctx.clip() + const hiGrad = bctx.createRadialGradient(cx - r * 0.3, cy - r * 0.38, 0, cx - r * 0.1, cy - r * 0.1, r * 0.65) + hiGrad.addColorStop(0, 'rgba(255,255,255,0.78)') + hiGrad.addColorStop(0.4, 'rgba(255,255,255,0.18)') + hiGrad.addColorStop(1, 'rgba(255,255,255,0)') + bctx.fillStyle = hiGrad + bctx.fillRect(0, 0, size, size) + bctx.restore() + return oc +} + function drawBalloon( ctx: CanvasRenderingContext2D, x: number, y: number, @@ -671,7 +750,7 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.fillRect(0, 0, w, h) // Nuts, gems & balloons - for (const { body, radius, colorIndex, kind, gemVariant, hitCount, templateIdx } of nuts) { + for (const { body, radius, colorIndex, kind, gemVariant, state: hitCount, templateIdx } of nuts) { const { position, angle } = body if (kind === 'balloon') { @@ -690,7 +769,29 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.rotate(-velAngle) } } - drawBalloon(ctx, 0, 0, radius, colorIndex) + // Cached body sphere (blur shadow pre-rendered once) + const balloonSprite = getBalloonBodySprite(radius, colorIndex) + ctx.drawImage(balloonSprite, -balloonSprite.width / 2, -balloonSprite.height / 2) + // Inline knot + string (fast, no filter) + const bknotH = Math.max(4, radius * 0.13) + const bstringLen = radius * 0.9 + const bc = BALLOON_COLORS[colorIndex % BALLOON_COLORS.length] + ctx.beginPath() + ctx.moveTo(0, radius + bknotH * 1.6) + ctx.lineTo(-bknotH * 0.55, radius) + ctx.lineTo(bknotH * 0.55, radius) + ctx.closePath() + ctx.fillStyle = bc.rim + ctx.fill() + ctx.beginPath() + ctx.moveTo(0, radius + bknotH * 1.6) + ctx.quadraticCurveTo(radius * 0.18, radius + bstringLen * 0.5, radius * 0.06, radius + bstringLen + bknotH * 1.6) + ctx.strokeStyle = bc.string + ctx.lineWidth = Math.max(1, radius * 0.025) + ctx.save() + ctx.globalAlpha *= 0.75 + ctx.stroke() + ctx.restore() ctx.restore() } else if (kind === 'stressball') { ctx.save() @@ -706,7 +807,28 @@ export function createRenderer(canvas: HTMLCanvasElement): Renderer { ctx.rotate(-velAngle) } } - drawStressBall(ctx, 0, 0, radius, colorIndex, squish) + // Cached body sprite (blur shadow pre-rendered once) + const sbSprite = getStressBallSprite(radius, colorIndex) + ctx.drawImage(sbSprite, -sbSprite.width / 2, -sbSprite.height / 2) + // Inline squish stress rings (fast, no filter) + if (squish > 0.05) { + const sc = STRESSBALL_COLORS[colorIndex % STRESSBALL_COLORS.length] + ctx.save() + ctx.beginPath() + ctx.arc(0, 0, radius, 0, Math.PI * 2) + ctx.clip() + ctx.globalAlpha = squish * 0.35 + ctx.strokeStyle = sc.shadow + ctx.lineWidth = Math.max(1, radius * 0.04) + ctx.beginPath() + ctx.arc(0, 0, radius * 0.6, 0, Math.PI * 2) + ctx.stroke() + ctx.globalAlpha = squish * 0.20 + ctx.beginPath() + ctx.arc(0, 0, radius * 0.35, 0, Math.PI * 2) + ctx.stroke() + ctx.restore() + } ctx.restore() } else if (kind === 'rock') { drawRock(ctx, body, radius, colorIndex, hitCount, templateIdx) diff --git a/src/share.ts b/src/share.ts index b192083..fa19f0d 100644 --- a/src/share.ts +++ b/src/share.ts @@ -63,20 +63,10 @@ const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction export function buildShareURL(nuts: NutBody[], nutRadius: number): string { const settingsPart = `z${Math.round(nutRadius)}` const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}` - // Fit items greedily + // Fit items greedily — encode each token via encodeNuts and count length + const allTokens = encodeNuts(nuts).split(',').filter(Boolean) const parts: string[] = [] - for (const n of nuts) { - const token = n.kind === 'gem' - ? `r${Math.round(n.radius)}g${n.gemVariant}` - : n.kind === 'balloon' - ? `r${Math.round(n.radius)}b${n.colorIndex % 7}` - : n.kind === 'metalnut' - ? `r${Math.round(n.radius)}m${n.colorIndex % 5}` - : n.kind === 'rock' - ? `r${Math.round(n.radius)}k${n.colorIndex % 4}` - : n.kind === 'stressball' - ? `r${Math.round(n.radius)}s${n.colorIndex % 6}` - : `r${Math.round(n.radius)}c${n.colorIndex % 5}` + for (const token of allTokens) { const candidate = base + encodeURIComponent(',' + [...parts, token].join(',')) if (candidate.length > MAX_URL_LENGTH) break parts.push(token)