fuzzyworld

nuts / goo / files / src / share.ts

Branch goo · default branch · all branches

share.ts

6.2 KB · history · blame

1import 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
10export interface SharedNut {
11 radius: number
12 colorIndex: number
13 kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball'
14 gemVariant: number
15}
16
17export interface ParsedShare {
18 nuts: SharedNut[]
19 nutRadius: number | null
20}
21
22export 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 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) }
41 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 }
43 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 }
45 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) }
47 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/'
57const MAX_URL_LENGTH = 1800 // comfortable QR limit at medium error-correction
58
59/**
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}`
73 : n.kind === 'metalnut'
74 ? `r${Math.round(n.radius)}m${n.colorIndex % 5}`
75 : n.kind === 'rock'
76 ? `r${Math.round(n.radius)}k${n.colorIndex % 4}`
77 : n.kind === 'stressball'
78 ? `r${Math.round(n.radius)}s${n.colorIndex % 6}`
79 : `r${Math.round(n.radius)}c${n.colorIndex % 5}`
80 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)}`
88}
89
90/** Parse share data from the current URL hash. Returns null if none present. */
91export function parseShareFromURL(): ParsedShare | null {
92 const m = location.hash.match(/[#&]s=([^&]*)/)
93 if (!m) return null
94 const raw = decodeURIComponent(m[1])
95 // Clear the hash so refreshing doesn't re-import
96 history.replaceState(null, '', location.pathname + location.search)
97 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 }
111}
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,
118 addBody: (x: number, y: number, radius: number, kind: 'nut' | 'gem' | 'balloon' | 'metalnut' | 'rock' | 'stressball', gemVariant: number) => void,
119 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}