fuzzyworld

nuts / files / src / share.ts

share.ts

6.0 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' | 'coin'
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 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
34export 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
59const BASE_URL = 'https://apps.fuzzyworld.net/nuts/'
60const 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 */
66export 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. */
84export 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. */
107export 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}