nuts / shampoo / files / src / share.ts
Branch shampoo · default branch · all branches
share.ts
f1262fd9 2026-04-12 Pavel Safronov | 1 | import type { NutBody } from './physics' |
| 2 | ||
| 3 | // ── Encoding ─────────────────────────────────────────────────────────────── | |
b008eb4c 2026-04-12 Pavel Safronov | 4 | // Settings prefix: "z<nutRadius>" e.g. "z130" |
f1262fd9 2026-04-12 Pavel Safronov | 5 | // Nuts: "r<radius>c<colorIndex>" e.g. "r130c2" |
| 6 | // Gems: "r<radius>g<gemVariant>" e.g. "r80g1" | |
b008eb4c 2026-04-12 Pavel Safronov | 7 | // Balloons: "r<radius>b<colorIndex>" e.g. "r80b3" |
| 8 | // Comma-separated after settings: "z130,r130c0,r80g1" | |
f1262fd9 2026-04-12 Pavel Safronov | 9 | |
| 10 | export interface SharedNut { | |
| 11 | radius: number | |
| 12 | colorIndex: number | |
a2e3ea1a 2026-04-13 Pavel Safronov | 13 | kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin' |
f1262fd9 2026-04-12 Pavel Safronov | 14 | gemVariant: number |
| 15 | } | |
| 16 | ||
b008eb4c 2026-04-12 Pavel Safronov | 17 | export interface ParsedShare { |
| 18 | nuts: SharedNut[] | |
| 19 | nutRadius: number | null | |
| 20 | } | |
| 21 | ||
f1262fd9 2026-04-12 Pavel Safronov | 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}` | |
a2b3b1f4 2026-04-12 Pavel Safronov | 26 | if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}` |
74d506c6 2026-04-12 Pavel Safronov | 27 | if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}` |
cfe80f49 2026-04-13 Pavel Safronov | 28 | if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}` |
a2e3ea1a 2026-04-13 Pavel Safronov | 29 | if (n.kind === 'coin') return `r${Math.round(n.radius)}o${n.gemVariant % 6}` |
f1262fd9 2026-04-12 Pavel Safronov | 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) } | |
a2b3b1f4 2026-04-12 Pavel Safronov | 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 } | |
74d506c6 2026-04-12 Pavel Safronov | 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 } | |
cfe80f49 2026-04-13 Pavel Safronov | 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) } | |
a2e3ea1a 2026-04-13 Pavel Safronov | 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) } | |
f1262fd9 2026-04-12 Pavel Safronov | 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://pavelsafronov.gitlab.io/nuts/' | |
b008eb4c 2026-04-12 Pavel Safronov | 60 | const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction |
f1262fd9 2026-04-12 Pavel Safronov | 61 | |
b008eb4c 2026-04-12 Pavel Safronov | 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)}` | |
5dba9120 2026-04-13 Pavel Safronov | 69 | // Fit items greedily — encode each token via encodeNuts and count length |
| 70 | const allTokens = encodeNuts(nuts).split(',').filter(Boolean) | |
b008eb4c 2026-04-12 Pavel Safronov | 71 | const parts: string[] = [] |
5dba9120 2026-04-13 Pavel Safronov | 72 | for (const token of allTokens) { |
b008eb4c 2026-04-12 Pavel Safronov | 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)}` | |
f1262fd9 2026-04-12 Pavel Safronov | 81 | } |
| 82 | ||
| 83 | /** Parse share data from the current URL hash. Returns null if none present. */ | |
b008eb4c 2026-04-12 Pavel Safronov | 84 | export function parseShareFromURL(): ParsedShare | null { |
f1262fd9 2026-04-12 Pavel Safronov | 85 | const m = location.hash.match(/[#&]s=([^&]*)/) |
| 86 | if (!m) return null | |
b008eb4c 2026-04-12 Pavel Safronov | 87 | const raw = decodeURIComponent(m[1]) |
f1262fd9 2026-04-12 Pavel Safronov | 88 | // Clear the hash so refreshing doesn't re-import |
| 89 | history.replaceState(null, '', location.pathname + location.search) | |
b008eb4c 2026-04-12 Pavel Safronov | 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 | } | |
f1262fd9 2026-04-12 Pavel Safronov | 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, | |
a2e3ea1a 2026-04-13 Pavel Safronov | 111 | addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball' | 'coin', gemVariant: number) => void, |
f1262fd9 2026-04-12 Pavel Safronov | 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 | } |