fuzzyworld

nuts / goo / files / src / share.ts

Branch goo · default branch · all branches

share.ts

Blame: the commit that last changed each line · file · history

f1262fd9 2026-04-12 Pavel Safronov1import type { NutBody } from './physics'
2
3// ── Encoding ───────────────────────────────────────────────────────────────
b008eb4c 2026-04-12 Pavel Safronov4// Settings prefix: "z<nutRadius>" e.g. "z130"
f1262fd9 2026-04-12 Pavel Safronov5// Nuts: "r<radius>c<colorIndex>" e.g. "r130c2"
6// Gems: "r<radius>g<gemVariant>" e.g. "r80g1"
b008eb4c 2026-04-12 Pavel Safronov7// Balloons: "r<radius>b<colorIndex>" e.g. "r80b3"
8// Comma-separated after settings: "z130,r130c0,r80g1"
f1262fd9 2026-04-12 Pavel Safronov9
10export interface SharedNut {
11 radius: number
12 colorIndex: number
cfe80f49 2026-04-13 Pavel Safronov13 kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
f1262fd9 2026-04-12 Pavel Safronov14 gemVariant: number
15}
16
b008eb4c 2026-04-12 Pavel Safronov17export interface ParsedShare {
18 nuts: SharedNut[]
19 nutRadius: number | null
20}
21
f1262fd9 2026-04-12 Pavel Safronov22export 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 Safronov26 if (n.kind === 'metalnut') return `r${Math.round(n.radius)}m${n.colorIndex % 5}`
74d506c6 2026-04-12 Pavel Safronov27 if (n.kind === 'rock') return `r${Math.round(n.radius)}k${n.colorIndex % 4}`
cfe80f49 2026-04-13 Pavel Safronov28 if (n.kind === 'stressball') return `r${Math.round(n.radius)}s${n.colorIndex % 6}`
f1262fd9 2026-04-12 Pavel Safronov29 return `r${Math.round(n.radius)}c${n.colorIndex % 5}`
30 }).join(',')
31}
32
33export function decodeNuts(encoded: string): SharedNut[] | null {
34 try {
35 if (!encoded) return null
36 return encoded.split(',').map(part => {
37 const gem = part.match(/^r(\d+)g(\d+)$/)
38 if (gem) return { radius: parseInt(gem[1], 10), colorIndex: 0, kind: 'gem' as const, gemVariant: parseInt(gem[2], 10) }
39 const balloon = part.match(/^r(\d+)b(\d+)$/)
40 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 Safronov41 const metal = part.match(/^r(\d+)m(\d+)$/)
42 if (metal) return { radius: parseInt(metal[1], 10), colorIndex: parseInt(metal[2], 10), kind: 'metalnut' as const, gemVariant: 0 }
74d506c6 2026-04-12 Pavel Safronov43 const rock = part.match(/^r(\d+)k(\d+)$/)
44 if (rock) return { radius: parseInt(rock[1], 10), colorIndex: parseInt(rock[2], 10), kind: 'rock' as const, gemVariant: 0 }
cfe80f49 2026-04-13 Pavel Safronov45 const stress = part.match(/^r(\d+)s(\d+)$/)
46 if (stress) return { radius: parseInt(stress[1], 10), colorIndex: parseInt(stress[2], 10), kind: 'stressball' as const, gemVariant: parseInt(stress[2], 10) }
f1262fd9 2026-04-12 Pavel Safronov47 const nut = part.match(/^r(\d+)c(\d+)$/)
48 if (nut) return { radius: parseInt(nut[1], 10), colorIndex: parseInt(nut[2], 10), kind: 'nut' as const, gemVariant: 0 }
49 throw new Error('invalid')
50 })
51 } catch {
52 return null
53 }
54}
55
56const BASE_URL = 'https://pavelsafronov.gitlab.io/nuts/'
b008eb4c 2026-04-12 Pavel Safronov57const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction
f1262fd9 2026-04-12 Pavel Safronov58
b008eb4c 2026-04-12 Pavel Safronov59/**
60 * Always returns a URL. Encodes the nut size setting first, then fits as many
61 * items as possible within MAX_URL_LENGTH (truncating from the end).
62 */
63export function buildShareURL(nuts: NutBody[], nutRadius: number): string {
64 const settingsPart = `z${Math.round(nutRadius)}`
65 const base = `${BASE_URL}#s=${encodeURIComponent(settingsPart)}`
66 // Fit items greedily
67 const parts: string[] = []
68 for (const n of nuts) {
69 const token = n.kind === 'gem'
70 ? `r${Math.round(n.radius)}g${n.gemVariant}`
71 : n.kind === 'balloon'
72 ? `r${Math.round(n.radius)}b${n.colorIndex % 7}`
a2b3b1f4 2026-04-12 Pavel Safronov73 : n.kind === 'metalnut'
74 ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
74d506c6 2026-04-12 Pavel Safronov75 : n.kind === 'rock'
76 ? `r${Math.round(n.radius)}k${n.colorIndex % 4}`
cfe80f49 2026-04-13 Pavel Safronov77 : n.kind === 'stressball'
78 ? `r${Math.round(n.radius)}s${n.colorIndex % 6}`
79 : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
b008eb4c 2026-04-12 Pavel Safronov80 const candidate = base + encodeURIComponent(',' + [...parts, token].join(','))
81 if (candidate.length > MAX_URL_LENGTH) break
82 parts.push(token)
83 }
84 const payload = parts.length > 0
85 ? `${settingsPart},${parts.join(',')}`
86 : settingsPart
87 return `${BASE_URL}#s=${encodeURIComponent(payload)}`
f1262fd9 2026-04-12 Pavel Safronov88}
89
90/** Parse share data from the current URL hash. Returns null if none present. */
b008eb4c 2026-04-12 Pavel Safronov91export function parseShareFromURL(): ParsedShare | null {
f1262fd9 2026-04-12 Pavel Safronov92 const m = location.hash.match(/[#&]s=([^&]*)/)
93 if (!m) return null
b008eb4c 2026-04-12 Pavel Safronov94 const raw = decodeURIComponent(m[1])
f1262fd9 2026-04-12 Pavel Safronov95 // Clear the hash so refreshing doesn't re-import
96 history.replaceState(null, '', location.pathname + location.search)
b008eb4c 2026-04-12 Pavel Safronov97 try {
98 const parts = raw.split(',')
99 let nutRadius: number | null = null
100 const nutParts: string[] = []
101 for (const part of parts) {
102 const setting = part.match(/^z(\d+)$/)
103 if (setting) { nutRadius = parseInt(setting[1], 10); continue }
104 nutParts.push(part)
105 }
106 const nuts = nutParts.length > 0 ? decodeNuts(nutParts.join(',')) ?? [] : []
107 return { nuts, nutRadius }
108 } catch {
109 return null
110 }
f1262fd9 2026-04-12 Pavel Safronov111}
112
113/** Place shared nuts/gems into the world with a loose random packing. */
114export function placeSharedNuts(
115 nuts: SharedNut[],
116 screenW: number,
117 screenH: number,
cfe80f49 2026-04-13 Pavel Safronov118 addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball', gemVariant: number) => void,
f1262fd9 2026-04-12 Pavel Safronov119 hasSpace: (x: number, y: number, r: number) => boolean,
120): void {
121 for (const { radius, colorIndex: _ci, kind, gemVariant } of nuts) {
122 // Attempt random positions until one fits (max 200 tries)
123 const margin = radius * 1.2
124 let placed = false
125 for (let attempt = 0; attempt < 200; attempt++) {
126 const x = margin + Math.random() * (screenW - margin * 2)
127 const y = margin + Math.random() * (screenH - margin * 2)
128 if (hasSpace(x, y, radius)) {
129 addBody(x, y, radius, kind, gemVariant)
130 placed = true
131 break
132 }
133 }
134 if (!placed) {
135 addBody(margin + Math.random() * (screenW - margin * 2), margin, radius, kind, gemVariant)
136 }
137 }
138}