| 1 | import type { NutBody } from './physics' |
| 2 | |
| 3 | // ── Encoding ─────────────────────────────────────────────────────────────── |
| 4 | // Settings prefix: "z<nutRadius>" e.g. "z130" |
| 5 | // Nuts: "r<radius>c<colorIndex>" e.g. "r130c2" |
| 6 | // Gems: "r<radius>g<gemVariant>" e.g. "r80g1" |
| 7 | // Balloons: "r<radius>b<colorIndex>" e.g. "r80b3" |
| 8 | // Comma-separated after settings: "z130,r130c0,r80g1" |
| 9 | |
| 10 | export interface SharedNut { |
| 11 | radius: number |
| 12 | colorIndex: number |
| 13 | kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin' |
| 14 | gemVariant: number |
| 15 | } |
| 16 | |
| 17 | export interface ParsedShare { |
| 18 | nuts: SharedNut[] |
| 19 | nutRadius: number | null |
| 20 | } |
| 21 | |
| 22 | export function encodeNuts(nuts: NutBody[]): string { |
| 23 | return nuts.map(n => { |
| 24 | if (n.kind === 'gem') return `r${Math.round(n.radius)}g${n.gemVariant}` |
| 25 | if (n.kind === 'balloon') return `r${Math.round(n.radius)}b${n.colorIndex % 7}` |
| 26 | if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}` |
| 27 | if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}` |
| 28 | if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}` |
| 29 | if (n.kind === 'coin') return `r${Math.round(n.radius)}o${n.gemVariant % 6}` |
| 30 | return `r${Math.round(n.radius)}c${n.colorIndex % 5}` |
| 31 | }).join(',') |
| 32 | } |
| 33 | |
| 34 | export function decodeNuts(encoded: string): SharedNut[] | null { |
| 35 | try { |
| 36 | if (!encoded) return null |
| 37 | return encoded.split(',').map(part => { |
| 38 | const gem = part.match(/^r(\d+)g(\d+)$/) |
| 39 | if (gem) return { radius: parseInt(gem[1], 10), colorIndex: 0, kind: 'gem' as const, gemVariant: parseInt(gem[2], 10) } |
| 40 | const balloon = part.match(/^r(\d+)b(\d+)$/) |
| 41 | if (balloon) return { radius: parseInt(balloon[1], 10), colorIndex: parseInt(balloon[2], 10), kind: 'balloon' as const, gemVariant: parseInt(balloon[2], 10) } |
| 42 | const metal = part.match(/^r(\d+)m(\d+)$/) |
| 43 | if (metal) return { radius: parseInt(metal[1], 10), colorIndex: parseInt(metal[2], 10), kind: 'metalnut' as const, gemVariant: 0 } |
| 44 | const rock = part.match(/^r(\d+)k(\d+)$/) |
| 45 | if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' as const, gemVariant: 0 } |
| 46 | const stress = part.match(/^r(\d+)s(\d+)$/) |
| 47 | if (stress) return { radius: parseInt(stress[1], 10), colorIndex: parseInt(stress[2], 10), kind: 'stressball' as const, gemVariant: parseInt(stress[2], 10) } |
| 48 | const coin = part.match(/^r(\d+)o(\d+)$/) |
| 49 | if (coin) return { radius: parseInt(coin[1], 10), colorIndex: parseInt(coin[2], 10), kind: 'coin' as const, gemVariant: parseInt(coin[2], 10) } |
| 50 | const nut = part.match(/^r(\d+)c(\d+)$/) |
| 51 | if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 } |
| 52 | throw new Error('invalid') |
| 53 | }) |
| 54 | } catch { |
| 55 | return null |
| 56 | } |
| 57 | } |
| 58 | |
| 59 | const BASE_URL = 'https://apps.fuzzyworld.net/nuts/' |
| 60 | const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction |
| 61 | |
| 62 | /** |
| 63 | * Always returns a URL. Encodes the nut size setting first, then fits as many |
| 64 | * items as possible within MAX_URL_LENGTH (truncating from the end). |
| 65 | */ |
| 66 | export function buildShareURL(nuts: NutBody[], nutRadius: number): string { |
| 67 | const settingsPart = `z${Math.round(nutRadius)}` |
| 68 | const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}` |
| 69 | // Fit items greedily — encode each token via encodeNuts and count length |
| 70 | const allTokens = encodeNuts(nuts).split(',').filter(Boolean) |
| 71 | const parts: string[] = [] |
| 72 | for (const token of allTokens) { |
| 73 | const candidate = base + encodeURIComponent(',' + [...parts, token].join(',')) |
| 74 | if (candidate.length > MAX_URL_LENGTH) break |
| 75 | parts.push(token) |
| 76 | } |
| 77 | const payload = parts.length > 0 |
| 78 | ? `${settingsPart},${parts.join(',')}` |
| 79 | : settingsPart |
| 80 | return `${BASE_URL}#s=${encodeURIComponent(payload)}` |
| 81 | } |
| 82 | |
| 83 | /** Parse share data from the current URL hash. Returns null if none present. */ |
| 84 | export function parseShareFromURL(): ParsedShare | null { |
| 85 | const m = location.hash.match(/[#&]s=([^&]*)/) |
| 86 | if (!m) return null |
| 87 | const raw = decodeURIComponent(m[1]) |
| 88 | // Clear the hash so refreshing doesn't re-import |
| 89 | history.replaceState(null, '', location.pathname + location.search) |
| 90 | try { |
| 91 | const parts = raw.split(',') |
| 92 | let nutRadius: number | null = null |
| 93 | const nutParts: string[] = [] |
| 94 | for (const part of parts) { |
| 95 | const setting = part.match(/^z(\d+)$/) |
| 96 | if (setting) { nutRadius = parseInt(setting[1], 10); continue } |
| 97 | nutParts.push(part) |
| 98 | } |
| 99 | const nuts = nutParts.length > 0 ? decodeNuts(nutParts.join(',')) ?? [] : [] |
| 100 | return { nuts, nutRadius } |
| 101 | } catch { |
| 102 | return null |
| 103 | } |
| 104 | } |
| 105 | |
| 106 | /** Place shared nuts/gems into the world with a loose random packing. */ |
| 107 | export function placeSharedNuts( |
| 108 | nuts: SharedNut[], |
| 109 | screenW: number, |
| 110 | screenH: number, |
| 111 | addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin', gemVariant: number) => void, |
| 112 | hasSpace: (x: number, y: number, r: number) => boolean, |
| 113 | ): void { |
| 114 | for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) { |
| 115 | // Attempt random positions until one fits (max 200 tries) |
| 116 | const margin = radius * 1.2 |
| 117 | let placed = false |
| 118 | for (let attempt = 0; attempt < 200; attempt++) { |
| 119 | const x = margin + Math.random() * (screenW - margin * 2) |
| 120 | const y = margin + Math.random() * (screenH - margin * 2) |
| 121 | if (hasSpace(x, y, radius)) { |
| 122 | addBody(x, y, radius, kind, gemVariant) |
| 123 | placed = true |
| 124 | break |
| 125 | } |
| 126 | } |
| 127 | if (!placed) { |
| 128 | addBody(margin + Math.random() * (screenW - margin * 2), margin, radius, kind, gemVariant) |
| 129 | } |
| 130 | } |
| 131 | } |