[READ-ONLY] Mirror of https://github.com/flo-bit/streamplace-overlay.
flo-bit.dev/streamplace-overlay/
89 kB
2280 lines
1/* ---------- SVG factories (fallback pets) ---------- */
2
3 const SVGS = {
4 cat: (color, accent) => `
5 <g class="body">
6 <g transform="translate(20 70)"><g class="tail-swish">
7 <path d="M0 0 Q -22 -10 -28 -34 Q -22 -42 -16 -36 Q -12 -18 0 -8 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
8 </g></g>
9 <g transform="translate(38 80)"><g class="leg-back">
10 <rect x="-6" y="0" width="12" height="22" rx="5" fill="${color}" stroke="${accent}" stroke-width="2"/>
11 <ellipse cx="0" cy="22" rx="8" ry="4" fill="${accent}"/>
12 </g></g>
13 <g transform="translate(70 80)"><g class="leg-front">
14 <rect x="-6" y="0" width="12" height="22" rx="5" fill="${color}" stroke="${accent}" stroke-width="2"/>
15 <ellipse cx="0" cy="22" rx="8" ry="4" fill="${accent}"/>
16 </g></g>
17 <ellipse cx="55" cy="74" rx="34" ry="22" fill="${color}" stroke="${accent}" stroke-width="2"/>
18 <ellipse cx="78" cy="80" rx="10" ry="8" fill="#fff7e6" opacity="0.85"/>
19 <g transform="translate(78 50)">
20 <path d="M-14 -10 L-18 -28 L-4 -18 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
21 <path d="M14 -10 L18 -28 L4 -18 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
22 <path d="M-14 -12 L-15 -22 L-7 -16 Z" fill="#ffc7d6"/>
23 <path d="M14 -12 L15 -22 L7 -16 Z" fill="#ffc7d6"/>
24 <circle cx="0" cy="0" r="20" fill="${color}" stroke="${accent}" stroke-width="2"/>
25 <circle cx="-10" cy="6" r="3" fill="#ffb3c1" opacity="0.6"/>
26 <circle cx="10" cy="6" r="3" fill="#ffb3c1" opacity="0.6"/>
27 <ellipse cx="-7" cy="-2" rx="2.5" ry="3.5" fill="#1a1a1a"/>
28 <ellipse cx="7" cy="-2" rx="2.5" ry="3.5" fill="#1a1a1a"/>
29 <circle cx="-6" cy="-3.5" r="0.9" fill="#fff"/>
30 <circle cx="8" cy="-3.5" r="0.9" fill="#fff"/>
31 <rect class="eye-lid" x="-10" y="-4" width="6" height="3" rx="1.5" fill="${accent}"/>
32 <rect class="eye-lid" x="4" y="-4" width="6" height="3" rx="1.5" fill="${accent}"/>
33 <path d="M-2 4 L2 4 L0 6 Z" fill="#ff7591"/>
34 <path d="M0 6 Q -3 9 -5 7 M0 6 Q 3 9 5 7" stroke="#1a1a1a" stroke-width="1.2" fill="none" stroke-linecap="round"/>
35 <path d="M-12 4 L-22 2 M-12 6 L-22 7 M12 4 L22 2 M12 6 L22 7" stroke="#1a1a1a" stroke-width="0.8" stroke-linecap="round" opacity="0.5"/>
36 </g>
37 </g>
38 `,
39 dog: (color, accent) => `
40 <g class="body">
41 <g transform="translate(22 72)"><g class="tail-wag">
42 <path d="M0 0 Q -10 -8 -8 -22 Q -2 -26 4 -20 Q 0 -8 0 0 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
43 </g></g>
44 <g transform="translate(40 80)"><g class="leg-back">
45 <rect x="-7" y="0" width="14" height="22" rx="6" fill="${color}" stroke="${accent}" stroke-width="2"/>
46 <ellipse cx="0" cy="22" rx="9" ry="4" fill="${accent}"/>
47 </g></g>
48 <g transform="translate(70 80)"><g class="leg-front">
49 <rect x="-7" y="0" width="14" height="22" rx="6" fill="${color}" stroke="${accent}" stroke-width="2"/>
50 <ellipse cx="0" cy="22" rx="9" ry="4" fill="${accent}"/>
51 </g></g>
52 <ellipse cx="55" cy="74" rx="34" ry="22" fill="${color}" stroke="${accent}" stroke-width="2"/>
53 <ellipse cx="78" cy="82" rx="11" ry="7" fill="#fff7e6" opacity="0.7"/>
54 <g transform="translate(80 50)">
55 <path d="M-18 -8 Q -26 -2 -22 14 Q -14 16 -10 6 Z" fill="${accent}"/>
56 <path d="M18 -8 Q 26 -2 22 14 Q 14 16 10 6 Z" fill="${accent}"/>
57 <circle cx="0" cy="0" r="20" fill="${color}" stroke="${accent}" stroke-width="2"/>
58 <ellipse cx="6" cy="8" rx="14" ry="9" fill="#fff7e6" stroke="${accent}" stroke-width="2"/>
59 <ellipse cx="-8" cy="-4" rx="9" ry="7" fill="${accent}" opacity="0.55"/>
60 <ellipse cx="-6" cy="-2" rx="2.6" ry="3.4" fill="#1a1a1a"/>
61 <ellipse cx="8" cy="-2" rx="2.6" ry="3.4" fill="#1a1a1a"/>
62 <circle cx="-5" cy="-3.5" r="0.9" fill="#fff"/>
63 <circle cx="9" cy="-3.5" r="0.9" fill="#fff"/>
64 <rect class="eye-lid" x="-9" y="-4" width="6" height="3" rx="1.5" fill="${accent}"/>
65 <rect class="eye-lid" x="5" y="-4" width="6" height="3" rx="1.5" fill="${accent}"/>
66 <ellipse cx="14" cy="6" rx="3" ry="2.4" fill="#1a1a1a"/>
67 <path d="M11 12 Q 14 18 17 12 Z" fill="#ff7591"/>
68 </g>
69 </g>
70 `,
71 bunny: (color, accent) => `
72 <g class="body">
73 <g transform="translate(34 84)">
74 <ellipse cx="0" cy="10" rx="14" ry="6" fill="${color}" stroke="${accent}" stroke-width="2"/>
75 </g>
76 <g transform="translate(70 88)">
77 <ellipse cx="0" cy="6" rx="10" ry="4" fill="${color}" stroke="${accent}" stroke-width="2"/>
78 </g>
79 <circle cx="22" cy="68" r="9" fill="#fff" stroke="${accent}" stroke-width="2"/>
80 <ellipse cx="54" cy="72" rx="30" ry="22" fill="${color}" stroke="${accent}" stroke-width="2"/>
81 <ellipse cx="74" cy="80" rx="10" ry="7" fill="#fff" opacity="0.9"/>
82 <g transform="translate(80 48)">
83 <g transform="translate(-6 -10)">
84 <ellipse cx="0" cy="-14" rx="5" ry="20" fill="${color}" stroke="${accent}" stroke-width="2"/>
85 <ellipse cx="0" cy="-14" rx="2.4" ry="14" fill="#ffc7d6"/>
86 </g>
87 <g transform="translate(8 -12) rotate(15)">
88 <ellipse cx="0" cy="-14" rx="5" ry="20" fill="${color}" stroke="${accent}" stroke-width="2"/>
89 <ellipse cx="0" cy="-14" rx="2.4" ry="14" fill="#ffc7d6"/>
90 </g>
91 <circle cx="0" cy="0" r="20" fill="${color}" stroke="${accent}" stroke-width="2"/>
92 <circle cx="-10" cy="6" r="3" fill="#ffb3c1" opacity="0.6"/>
93 <circle cx="10" cy="6" r="3" fill="#ffb3c1" opacity="0.6"/>
94 <ellipse cx="-7" cy="-1" rx="2.4" ry="3.2" fill="#1a1a1a"/>
95 <ellipse cx="7" cy="-1" rx="2.4" ry="3.2" fill="#1a1a1a"/>
96 <circle cx="-6" cy="-2.5" r="0.9" fill="#fff"/>
97 <circle cx="8" cy="-2.5" r="0.9" fill="#fff"/>
98 <rect class="eye-lid" x="-10" y="-3" width="6" height="3" rx="1.5" fill="${accent}"/>
99 <rect class="eye-lid" x="4" y="-3" width="6" height="3" rx="1.5" fill="${accent}"/>
100 <path d="M-2 5 L2 5 L0 7 Z" fill="#ff7591"/>
101 <path d="M0 7 Q -3 10 -5 8 M0 7 Q 3 10 5 8" stroke="#1a1a1a" stroke-width="1" fill="none" stroke-linecap="round"/>
102 </g>
103 </g>
104 `,
105 duck: (color, accent) => `
106 <g class="body">
107 <g transform="translate(40 90)"><g class="leg-back"><path d="M-8 0 L8 0 L4 6 L-4 6 Z" fill="#ff8e3c"/></g></g>
108 <g transform="translate(64 90)"><g class="leg-front"><path d="M-8 0 L8 0 L4 6 L-4 6 Z" fill="#ff8e3c"/></g></g>
109 <ellipse cx="52" cy="74" rx="30" ry="22" fill="${color}" stroke="${accent}" stroke-width="2"/>
110 <path d="M40 70 Q 50 60 64 68 Q 60 80 46 80 Z" fill="${accent}" opacity="0.55"/>
111 <path d="M22 66 L14 60 L18 70 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
112 <g transform="translate(78 56)">
113 <circle cx="0" cy="0" r="18" fill="${color}" stroke="${accent}" stroke-width="2"/>
114 <path d="M14 2 Q 28 0 28 6 Q 28 12 14 10 Z" fill="#ff8e3c" stroke="${accent}" stroke-width="1.5"/>
115 <line x1="16" y1="6" x2="26" y2="6" stroke="${accent}" stroke-width="0.8"/>
116 <circle cx="4" cy="-3" r="3" fill="#1a1a1a"/>
117 <circle cx="5" cy="-4" r="1" fill="#fff"/>
118 <rect class="eye-lid" x="1" y="-5" width="6" height="3" rx="1.5" fill="${accent}"/>
119 <circle cx="-2" cy="6" r="3" fill="#ffb3c1" opacity="0.5"/>
120 </g>
121 </g>
122 `,
123 hamster: (color, accent) => `
124 <g class="body">
125 <g transform="translate(40 86)"><g class="leg-back"><ellipse cx="0" cy="6" rx="8" ry="4" fill="${color}" stroke="${accent}" stroke-width="1.5"/></g></g>
126 <g transform="translate(66 88)"><g class="leg-front"><ellipse cx="0" cy="4" rx="7" ry="3.5" fill="${color}" stroke="${accent}" stroke-width="1.5"/></g></g>
127 <ellipse cx="56" cy="78" rx="30" ry="18" fill="${color}" stroke="${accent}" stroke-width="2"/>
128 <ellipse cx="72" cy="84" rx="11" ry="6" fill="#fff7e6" opacity="0.8"/>
129 <g transform="translate(78 64)">
130 <circle cx="-8" cy="-14" r="5" fill="${color}" stroke="${accent}" stroke-width="1.5"/>
131 <circle cx="8" cy="-14" r="5" fill="${color}" stroke="${accent}" stroke-width="1.5"/>
132 <circle cx="-8" cy="-14" r="2.5" fill="#ffc7d6"/>
133 <circle cx="8" cy="-14" r="2.5" fill="#ffc7d6"/>
134 <circle cx="0" cy="0" r="16" fill="${color}" stroke="${accent}" stroke-width="2"/>
135 <circle cx="-12" cy="6" r="6" fill="${color}" stroke="${accent}" stroke-width="1.5"/>
136 <circle cx="12" cy="6" r="6" fill="${color}" stroke="${accent}" stroke-width="1.5"/>
137 <circle cx="-12" cy="7" r="3" fill="#ffb3c1" opacity="0.55"/>
138 <circle cx="12" cy="7" r="3" fill="#ffb3c1" opacity="0.55"/>
139 <circle cx="-5" cy="-1" r="2.4" fill="#1a1a1a"/>
140 <circle cx="5" cy="-1" r="2.4" fill="#1a1a1a"/>
141 <circle cx="-4" cy="-2" r="0.8" fill="#fff"/>
142 <circle cx="6" cy="-2" r="0.8" fill="#fff"/>
143 <rect class="eye-lid" x="-7" y="-3" width="5" height="3" rx="1.5" fill="${accent}"/>
144 <rect class="eye-lid" x="3" y="-3" width="5" height="3" rx="1.5" fill="${accent}"/>
145 <ellipse cx="0" cy="4" rx="1.6" ry="1.2" fill="#1a1a1a"/>
146 </g>
147 </g>
148 `,
149 goose: (color, accent) => `
150 <g class="body">
151 <g transform="translate(42 92)"><g class="leg-back"><path d="M-9 0 L9 0 L5 6 L-5 6 Z" fill="#ff8e3c" stroke="#cc6600" stroke-width="1"/></g></g>
152 <g transform="translate(64 92)"><g class="leg-front"><path d="M-9 0 L9 0 L5 6 L-5 6 Z" fill="#ff8e3c" stroke="#cc6600" stroke-width="1"/></g></g>
153 <path d="M22 70 L8 62 L14 80 Z" fill="${color}" stroke="${accent}" stroke-width="2"/>
154 <ellipse cx="50" cy="76" rx="32" ry="18" fill="${color}" stroke="${accent}" stroke-width="2"/>
155 <path d="M30 70 Q 50 58 68 68 Q 64 86 38 84 Z" fill="${color}" stroke="${accent}" stroke-width="1.5"/>
156 <path d="M44 76 Q 52 80 60 76" stroke="${accent}" stroke-width="1" fill="none"/>
157 <path d="M48 80 Q 54 83 60 80" stroke="${accent}" stroke-width="1" fill="none"/>
158 <path d="M 68 64 C 84 58, 90 38, 88 26"
159 stroke="${accent}" stroke-width="18" stroke-linecap="round" fill="none"/>
160 <path d="M 68 64 C 84 58, 90 38, 88 26"
161 stroke="${color}" stroke-width="14" stroke-linecap="round" fill="none"/>
162 <g transform="translate(88 22)">
163 <ellipse cx="0" cy="0" rx="13" ry="11" fill="${color}" stroke="${accent}" stroke-width="2"/>
164 <ellipse cx="9" cy="-1" rx="3" ry="2.5" fill="#ff8e3c" stroke="#cc6600" stroke-width="1"/>
165 <path d="M9 0 Q 26 -2 28 4 Q 26 10 9 7 Z" fill="#ff8e3c" stroke="#cc6600" stroke-width="1.2"/>
166 <line x1="12" y1="3" x2="24" y2="3" stroke="#cc6600" stroke-width="0.7"/>
167 <circle cx="2" cy="-2" r="2.2" fill="#1a1a1a"/>
168 <circle cx="2.7" cy="-2.7" r="0.7" fill="#fff"/>
169 <rect class="eye-lid" x="-0.5" y="-3.5" width="5" height="2.5" rx="1.2" fill="${accent}"/>
170 <circle cx="-4" cy="3" r="2" fill="#ffb3c1" opacity="0.4"/>
171 </g>
172 </g>
173 `,
174 };
175
176 const ZZZ_SVG = `
177 <svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
178 <text x="2" y="14" font-family="system-ui, sans-serif" font-size="14" font-weight="700" fill="#fff" stroke="#3a3a3a" stroke-width="0.6">z</text>
179 <text x="10" y="22" font-family="system-ui, sans-serif" font-size="18" font-weight="700" fill="#fff" stroke="#3a3a3a" stroke-width="0.6">z</text>
180 <text x="20" y="32" font-family="system-ui, sans-serif" font-size="22" font-weight="700" fill="#fff" stroke="#3a3a3a" stroke-width="0.6">Z</text>
181 </svg>
182 `;
183 const HEART_SVG = `
184 <svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
185 <path d="M12 21s-7-4.5-9.5-9A5.5 5.5 0 0 1 12 6a5.5 5.5 0 0 1 9.5 6C19 16.5 12 21 12 21z"
186 fill="#ff5577" stroke="#fff" stroke-width="1.5"/>
187 </svg>
188 `;
189 // Cartoon hand for !pet — palm-down so it reads as patting a head from above.
190 const HAND_SVG = `
191 <svg viewBox="0 0 36 36" xmlns="http://www.w3.org/2000/svg">
192 <g stroke="#3a2418" stroke-width="1.4" stroke-linejoin="round" stroke-linecap="round">
193 <rect x="11" y="22" width="14" height="9" rx="3" fill="#f4c393"/>
194 <path d="M9 22 Q 8 14 12 12 Q 14 12 14 16 L 14 23 Z" fill="#f4c393"/>
195 <path d="M13 22 Q 12 9 16 8 Q 19 8 18.5 13 L 18.5 23 Z" fill="#f4c393"/>
196 <path d="M17 22 Q 16 7 21 6.5 Q 24 7 23 13 L 23 23 Z" fill="#f4c393"/>
197 <path d="M21 22 Q 21 10 25.5 10 Q 28 10 27 16 L 27 23 Z" fill="#f4c393"/>
198 <path d="M25 24 Q 30 22 31 26 Q 30 29 25 28 Z" fill="#f4c393"/>
199 </g>
200 </svg>
201 `;
202 const FART_GREENS = [
203 "#9bbf2f",
204 "#aacc3a",
205 "#b8d650",
206 "#8eb02a",
207 "#7ea020",
208 "#c4dc66",
209 "#a3c544",
210 "#88a52a",
211 "#d4e878",
212 ];
213 const SMOKE_GRAYS = [
214 "#dadada",
215 "#cccccc",
216 "#bbbbbb",
217 "#aaaaaa",
218 "#c8c8c8",
219 "#9c9c9c",
220 "#e8e8e8",
221 ];
222
223 function randomSmokeSvg() {
224 const n = 5 + Math.floor(Math.random() * 4);
225 const rand = (a, b) => a + Math.random() * (b - a);
226 let body = "";
227 let maxDur = 0;
228 for (let i = 0; i < n; i++) {
229 const cx = rand(20, 50);
230 const cy = rand(55, 75);
231 const r = rand(7, 16);
232 const fill =
233 SMOKE_GRAYS[Math.floor(Math.random() * SMOKE_GRAYS.length)];
234 const peak = rand(0.45, 0.75);
235 const dx = rand(-14, 14);
236 const dy = rand(-35, -65);
237 const startScale = rand(0.15, 0.32);
238 const endScale = rand(1.4, 2.4);
239 const duration = rand(1.5, 2.8);
240 const delay = rand(0, 0.6);
241 if (duration + delay > maxDur) maxDur = duration + delay;
242 body += `<circle class="smoke-puff" cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="${fill}" style="animation-duration:${duration.toFixed(2)}s;animation-delay:${delay.toFixed(2)}s;--smoke-start:${startScale.toFixed(2)};--smoke-end:${endScale.toFixed(2)};--smoke-dx:${dx.toFixed(1)}px;--smoke-dy:${dy.toFixed(1)}px;--smoke-peak:${peak.toFixed(2)}"/>`;
243 }
244 return {
245 svg: `<svg viewBox="0 0 70 90" xmlns="http://www.w3.org/2000/svg">${body}</svg>`,
246 durationMs: maxDur * 1000 + 100,
247 };
248 }
249
250 function randomFartSvg() {
251 const n = 4 + Math.floor(Math.random() * 5); // 4–8 puffs
252 const rand = (a, b) => a + Math.random() * (b - a);
253 let body = "";
254 let maxDur = 0;
255 for (let i = 0; i < n; i++) {
256 const cx = rand(12, 78);
257 const cy = rand(34, 58);
258 const r = rand(5, 17);
259 const fill =
260 FART_GREENS[Math.floor(Math.random() * FART_GREENS.length)];
261 const peak = rand(0.5, 0.9);
262 const dx = rand(-22, 22);
263 const dy = rand(-12, -42);
264 const startScale = rand(0.12, 0.35);
265 const endScale = rand(1.0, 2.0);
266 const duration = rand(0.9, 1.9);
267 const delay = rand(0, 0.5);
268 if (duration + delay > maxDur) maxDur = duration + delay;
269 body += `<circle class="fart-puff" cx="${cx.toFixed(1)}" cy="${cy.toFixed(1)}" r="${r.toFixed(1)}" fill="${fill}" style="animation-duration:${duration.toFixed(2)}s;animation-delay:${delay.toFixed(2)}s;--fart-start:${startScale.toFixed(2)};--fart-end:${endScale.toFixed(2)};--fart-dx:${dx.toFixed(1)}px;--fart-dy:${dy.toFixed(1)}px;--fart-peak:${peak.toFixed(2)}"/>`;
270 }
271 return {
272 svg: `<svg viewBox="0 0 90 70" xmlns="http://www.w3.org/2000/svg">${body}</svg>`,
273 durationMs: maxDur * 1000 + 100,
274 };
275 }
276
277 /* ---------- Pet variants (SVG fallback) ---------- */
278
279 const PET_VARIANTS = {
280 cat: [
281 { c: "#f4a23c", a: "#cc7a18" },
282 { c: "#3a3a3a", a: "#1a1a1a" },
283 { c: "#fafafa", a: "#a8a8a8" },
284 { c: "#a87752", a: "#5e3d24" },
285 { c: "#cccccc", a: "#7a7a7a" },
286 ],
287 dog: [
288 { c: "#e8c39e", a: "#a87645" },
289 { c: "#5a3d2a", a: "#332010" },
290 { c: "#fafafa", a: "#a8a8a8" },
291 { c: "#d8a878", a: "#8e5e30" },
292 ],
293 bunny: [
294 { c: "#fafafa", a: "#bdbdbd" },
295 { c: "#d8b899", a: "#9c7a5a" },
296 { c: "#3a3a3a", a: "#1a1a1a" },
297 { c: "#c8b4d4", a: "#8c6e9e" },
298 ],
299 duck: [
300 { c: "#ffd93d", a: "#cf9a00" },
301 { c: "#fafafa", a: "#bdbdbd" },
302 ],
303 hamster: [
304 { c: "#caa472", a: "#8e6a3f" },
305 { c: "#fafafa", a: "#a8a8a8" },
306 { c: "#e8b8a0", a: "#b07858" },
307 ],
308 goose: [{ c: "#ffffff", a: "#bcbcbc" }],
309 };
310
311 const PET_BASE = {
312 cat: { speed: 35, scale: 1.0 },
313 dog: { speed: 50, scale: 1.05 },
314 bunny: { speed: 0, scale: 0.95 },
315 duck: { speed: 28, scale: 0.9 },
316 hamster: { speed: 60, scale: 0.75 },
317 goose: { speed: 32, scale: 1.1 },
318 };
319
320 const RANDOM_TYPES = ["cat", "dog", "bunny", "duck", "hamster"];
321
322 function hashString(s) {
323 let h = 5381;
324 for (let i = 0; i < s.length; i++)
325 h = ((h << 5) + h + s.charCodeAt(i)) | 0;
326 return Math.abs(h);
327 }
328
329 function clamp(v, [lo, hi]) {
330 return Math.max(lo, Math.min(hi, v));
331 }
332
333 /* Bad-word redaction for chat bubbles. Strong profanity only; mild words pass through. */
334 const BAD_WORDS_RE =
335 /\b(fuck|shit|bitch|cunt|nigger|nigga|faggot|fag|whore|slut|asshole|arsehole|cock|wank|twat|pussy|scheisse|scheiße|arschloch|fotze|hure|wichser)([a-zäöü]*)\b/giu;
336 function redactBadWords(s) {
337 return s.replace(BAD_WORDS_RE, (m) => "*".repeat(m.length));
338 }
339
340 // User-chosen pet type, takes precedence over both sprite and handle-based picks.
341 // did → 'cat' | 'dog' | 'bunny' | 'duck' | 'hamster' | 'goose'
342 const petTypeOverrides = new Map();
343
344 function pickPet(did, handle, sprite) {
345 const override = petTypeOverrides.get(did);
346 if (override && PET_VARIANTS[override]) {
347 const h = hashString(did);
348 const v =
349 PET_VARIANTS[override][(h >> 5) % PET_VARIANTS[override].length];
350 return {
351 mode: "svg",
352 type: override,
353 color: v.c,
354 accent: v.a,
355 ...PET_BASE[override],
356 };
357 }
358 if (sprite) {
359 return { mode: "sprite", sprite, scale: 1.0, speed: 42 };
360 }
361 const lower = (handle || "").toLowerCase();
362 if (lower.includes("goose")) {
363 const v = PET_VARIANTS.goose[0];
364 return {
365 mode: "svg",
366 type: "goose",
367 color: v.c,
368 accent: v.a,
369 ...PET_BASE.goose,
370 };
371 }
372 // Per chat (felinus.fish): handles containing "fish" become cats.
373 if (lower.includes("fish")) {
374 const h = hashString(did);
375 const v = PET_VARIANTS.cat[(h >> 5) % PET_VARIANTS.cat.length];
376 return {
377 mode: "svg",
378 type: "cat",
379 color: v.c,
380 accent: v.a,
381 ...PET_BASE.cat,
382 };
383 }
384 const h = hashString(did);
385 const type = RANDOM_TYPES[h % RANDOM_TYPES.length];
386 const v = PET_VARIANTS[type][(h >> 5) % PET_VARIANTS[type].length];
387 return {
388 mode: "svg",
389 type,
390 color: v.c,
391 accent: v.a,
392 ...PET_BASE[type],
393 };
394 }
395
396 /* ---------- Pet ---------- */
397
398 const SPRITE_DISPLAY_H = 96; // px tall when rendered
399 const WALK_CYCLE = [1, 0, 1, 2];
400 const WALK_FPS = 6;
401
402 /* Lazy-build a radial displacement map for the bulge filter. R/G channels
403 encode (dx, dy) sample offsets — center → no shift, mid-radius → strongly
404 outward push (so center magnifies / edges compress = fisheye / bulge). */
405 let _bulgeMapDataUrl = null;
406 function getBulgeDisplacementMap() {
407 if (_bulgeMapDataUrl) return _bulgeMapDataUrl;
408 const size = 128;
409 const c = document.createElement("canvas");
410 c.width = c.height = size;
411 const ctx = c.getContext("2d");
412 const imgData = ctx.createImageData(size, size);
413 const cx = (size - 1) / 2,
414 cy = (size - 1) / 2;
415 const maxR = size / 2;
416 for (let y = 0; y < size; y++) {
417 for (let x = 0; x < size; x++) {
418 const dx = x - cx,
419 dy = y - cy;
420 const d = Math.sqrt(dx * dx + dy * dy);
421 const t = Math.min(1, d / maxR);
422 // Outward-pointing unit vector
423 const ux = d > 0.0001 ? dx / d : 0;
424 const uy = d > 0.0001 ? dy / d : 0;
425 // Magnitude: 0 at center, peaks at ~0.6 of radius, 0 at edge — gives
426 // the classic barrel-bulge profile (centre stretches, edges fall back).
427 // Vectors point INWARD (toward center): a slightly-off-center output
428 // pixel samples from a point even closer to centre, so the centre
429 // appears magnified — a true bulge / convex lens. (An outward-pointing
430 // map gives the opposite, pinch-style distortion.)
431 const mag = Math.sin(t * Math.PI);
432 const i = (y * size + x) * 4;
433 imgData.data[i] = Math.round(128 - ux * mag * 127);
434 imgData.data[i + 1] = Math.round(128 - uy * mag * 127);
435 imgData.data[i + 2] = 128;
436 imgData.data[i + 3] = 255;
437 }
438 }
439 ctx.putImageData(imgData, 0, 0);
440 _bulgeMapDataUrl = c.toDataURL("image/png");
441 return _bulgeMapDataUrl;
442 }
443
444 class Pet {
445 constructor(spec, stage, handle) {
446 this.mode = spec.mode;
447 this.scale = spec.scale;
448 this.speed = spec.speed;
449 this.lastMessage = performance.now();
450 this.isHopper = spec.mode === "svg" && spec.type === "bunny";
451
452 this.el = document.createElement("div");
453 this.el.className = `pet spawning ${spec.mode === "svg" ? spec.type : "sprite"}`;
454
455 this.flipEl = document.createElement("div");
456 this.flipEl.className = "pet-flip";
457
458 if (spec.mode === "svg") {
459 this.flipEl.innerHTML = `
460 <svg viewBox="0 0 110 110" xmlns="http://www.w3.org/2000/svg">
461 ${SVGS[spec.type](spec.color, spec.accent)}
462 </svg>
463 `;
464 this.visualHeight = 110 * this.scale;
465 } else {
466 const sp = spec.sprite;
467 const ratio = SPRITE_DISPLAY_H / sp.frameHeight;
468 this.spriteFrameW = sp.frameWidth * ratio;
469 this.spriteFrameH = sp.frameHeight * ratio;
470 this.spriteEl = document.createElement("div");
471 this.spriteEl.className = "rpg-sprite";
472 this.spriteEl.style.backgroundImage = `url("${sp.imageUrl}")`;
473 this.spriteEl.style.backgroundSize = `${sp.width * ratio}px ${sp.height * ratio}px`;
474 this.spriteEl.style.width = `${this.spriteFrameW}px`;
475 this.spriteEl.style.height = `${this.spriteFrameH}px`;
476 this.spriteEl.style.marginLeft = `${-this.spriteFrameW / 2}px`;
477 this.sprite = sp;
478 this.flipEl.appendChild(this.spriteEl);
479 this.visualHeight = this.spriteFrameH;
480 }
481
482 this.zzzEl = document.createElement("div");
483 this.zzzEl.className = "zzz";
484 this.zzzEl.innerHTML = ZZZ_SVG;
485
486 this.heartEl = document.createElement("div");
487 this.heartEl.className = "heart";
488 this.heartEl.innerHTML = HEART_SVG;
489
490 this.fartEl = document.createElement("div");
491 this.fartEl.className = "fart";
492
493 this.smokeEl = document.createElement("div");
494 this.smokeEl.className = "smoke";
495
496 this.rubEl = document.createElement("div");
497 this.rubEl.className = "pet-rub";
498 this.rubEl.innerHTML = HAND_SVG;
499
500 this.labelEl = document.createElement("div");
501 this.labelEl.className = "pet-label";
502
503 this.el.appendChild(this.flipEl);
504 this.el.appendChild(this.zzzEl);
505 this.el.appendChild(this.heartEl);
506 this.el.appendChild(this.fartEl);
507 this.el.appendChild(this.smokeEl);
508 this.el.appendChild(this.rubEl);
509 this.el.appendChild(this.labelEl);
510
511 this.setHandle(handle);
512
513 const fromLeft = Math.random() < 0.5;
514 this.x = fromLeft ? -130 : window.innerWidth + 20;
515 this.dir = fromLeft ? 1 : -1;
516
517 this.state = "walk";
518 this.stateTimer = 0;
519 this.nextDecision = 4 + Math.random() * 5;
520 this.happyUntil = 0;
521 this.speedMul = 1;
522 this.depthScale = 1;
523 this.stretchX = 1;
524 this.stretchY = 1;
525 this.bulgeLevel = 0;
526 this.blurLevel = 0;
527 this.filterId = Math.random().toString(36).slice(2, 10);
528
529 stage.appendChild(this.el);
530 this.applyTransform();
531
532 if (spec.mode === "svg") {
533 this.flipEl
534 .querySelectorAll(
535 ".body, .leg-front, .leg-back, .tail-wag, .tail-swish, .eye-lid",
536 )
537 .forEach(
538 (n) => (n.style.animationDelay = `${Math.random() * -2}s`),
539 );
540 }
541
542 setTimeout(() => this.el.classList.remove("spawning"), 700);
543 }
544
545 setHandle(handle) {
546 this.handle = handle;
547 if (!this.showingMessage) {
548 this.labelEl.textContent = handle ? "@" + handle : "";
549 }
550 }
551
552 applyTransform() {
553 this.el.style.transform = `translate3d(${this.x}px, 0, 0) scale(${this.depthScale})`;
554 if (this.mode === "svg") {
555 const flip = this.dir < 0 ? -1 : 1;
556 this.flipEl.style.transform = `scale(${this.scale * flip * this.stretchX}, ${this.scale * this.stretchY})`;
557 } else {
558 this.flipEl.style.transform = `scale(${this.scale * this.stretchX}, ${this.scale * this.stretchY})`;
559 }
560 this.labelEl.style.top = "auto";
561 this.labelEl.style.bottom = `${this.visualHeight + 8}px`;
562 // Position fart cloud behind the pet (opposite of facing direction)
563 this.fartEl.style.left = this.dir > 0 ? "-30px" : "auto";
564 this.fartEl.style.right = this.dir > 0 ? "auto" : "-30px";
565 // Bigger pets render in front
566 this.el.style.zIndex = Math.round(this.depthScale * 100);
567 }
568
569 updateSpriteFrame() {
570 if (!this.spriteEl) return;
571 const sp = this.sprite;
572 let row, col;
573 if (this.state === "sleep") {
574 row = 0;
575 col = 1;
576 } else if (this.state === "walk") {
577 row = this.dir < 0 ? 1 : 2;
578 const fps = WALK_FPS * this.speedMul;
579 const idx =
580 Math.floor(performance.now() / (1000 / fps)) % WALK_CYCLE.length;
581 col = WALK_CYCLE[idx];
582 } else {
583 row = this.dir < 0 ? 1 : 2;
584 col = 1;
585 }
586 row = Math.min(row, sp.rows - 1);
587 col = Math.min(col, sp.columns - 1);
588 this.spriteEl.style.backgroundPosition = `${-col * this.spriteFrameW}px ${-row * this.spriteFrameH}px`;
589 }
590
591 setState(s) {
592 if (this.state === s) return;
593 this.el.classList.remove("idle", "sleep", "happy");
594 this.state = s;
595 if (s === "idle") this.el.classList.add("idle");
596 if (s === "sleep") this.el.classList.add("sleep");
597 if (s === "happy") {
598 this.el.classList.add("happy");
599 this.happyUntil = performance.now() + 1200;
600 }
601 }
602
603 setDirection(dir) {
604 this.dir = dir;
605 if (this.state !== "walk") this.setState("walk");
606 this.lastMessage = performance.now();
607 saveStateDebounced();
608 }
609
610 setSpeedMul(mul) {
611 this.speedMul = clamp(mul, LIMITS.speedMul);
612 this.el.style.setProperty("--anim-speed", this.speedMul);
613 if (this.state !== "walk") this.setState("walk");
614 this.lastMessage = performance.now();
615 saveStateDebounced();
616 }
617
618 setDepthScale(scale) {
619 this.depthScale = clamp(scale, LIMITS.depthScale);
620 this._rebalance("depthScale");
621 this.lastMessage = performance.now();
622 saveStateDebounced();
623 }
624
625 setStretchX(mul) {
626 this.stretchX = clamp(mul, LIMITS.stretchX);
627 this._rebalance("stretchX");
628 this.lastMessage = performance.now();
629 saveStateDebounced();
630 }
631
632 setStretchY(mul) {
633 this.stretchY = clamp(mul, LIMITS.stretchY);
634 this._rebalance("stretchY");
635 this.lastMessage = performance.now();
636 saveStateDebounced();
637 }
638
639 // !reset — restore all per-pet transforms to defaults.
640 resetAll() {
641 this.speedMul = 1;
642 this.depthScale = 1;
643 this.stretchX = 1;
644 this.stretchY = 1;
645 this.el.style.setProperty("--anim-speed", 1);
646 this.setBulgeLevel(0);
647 this.setBlurLevel(0);
648 this.lastMessage = performance.now();
649 this.applyTransform();
650 saveStateDebounced();
651 }
652
653 // Bulge / fisheye: real radial-pinch distortion via feDisplacementMap fed by
654 // a canvas-generated vector field. Stacked with `bulgex3` etc.
655 setBulgeLevel(level) {
656 this.bulgeLevel = clamp(level, LIMITS.bulgeLevel);
657 const filterId = `bulge-${this.filterId}`;
658 const defs = document.querySelector("#filter-defs defs");
659 let filter = document.getElementById(filterId);
660 if (this.bulgeLevel <= 0) {
661 if (filter) filter.remove();
662 } else {
663 const NS = "http://www.w3.org/2000/svg";
664 if (!filter) {
665 filter = document.createElementNS(NS, "filter");
666 filter.id = filterId;
667 filter.setAttribute("x", "-30%");
668 filter.setAttribute("y", "-30%");
669 filter.setAttribute("width", "160%");
670 filter.setAttribute("height", "160%");
671 filter.setAttribute("color-interpolation-filters", "sRGB");
672 const feImage = document.createElementNS(NS, "feImage");
673 feImage.setAttributeNS(
674 "http://www.w3.org/1999/xlink",
675 "xlink:href",
676 getBulgeDisplacementMap(),
677 );
678 feImage.setAttribute("href", getBulgeDisplacementMap());
679 feImage.setAttribute("result", "map");
680 feImage.setAttribute("preserveAspectRatio", "none");
681 feImage.setAttribute("width", "100%");
682 feImage.setAttribute("height", "100%");
683 filter.appendChild(feImage);
684 const disp = document.createElementNS(NS, "feDisplacementMap");
685 disp.setAttribute("in", "SourceGraphic");
686 disp.setAttribute("in2", "map");
687 disp.setAttribute("xChannelSelector", "R");
688 disp.setAttribute("yChannelSelector", "G");
689 filter.appendChild(disp);
690 defs.appendChild(filter);
691 }
692 // Each level = a noticeable but bounded distortion.
693 const disp = filter.querySelector("feDisplacementMap");
694 disp.setAttribute("scale", String(this.bulgeLevel * 14));
695 }
696 this._applyVisualFilters();
697 this.lastMessage = performance.now();
698 saveStateDebounced();
699 }
700
701 // Per-pet blur — `blur`/`blurx5` add ≈2px steps; `unblur` clears.
702 setBlurLevel(level) {
703 this.blurLevel = clamp(level, LIMITS.blurLevel);
704 this._applyVisualFilters();
705 this.lastMessage = performance.now();
706 saveStateDebounced();
707 }
708
709 // Combine bulge (SVG filter) and blur (CSS function) into the single
710 // filter chain on flipEl. Order matters: bulge first warps the source,
711 // then blur softens — feels more like an out-of-focus bulge than a
712 // bulge-of-blur.
713 _applyVisualFilters() {
714 const parts = [];
715 if (this.bulgeLevel > 0) parts.push(`url(#bulge-${this.filterId})`);
716 if (this.blurLevel > 0) parts.push(`blur(${this.blurLevel * 2}px)`);
717 this.flipEl.style.filter = parts.join(" ");
718 }
719
720 // Conservation mode: total visible area (depthScale^2 * stretchX * stretchY) stays ≈1.
721 // When the user changes one dimension, the other two scale to compensate.
722 _rebalance(changedKey) {
723 if (!CONSERVATION) return;
724 const product = this.depthScale * this.stretchX * this.stretchY;
725 if (product <= 0) return;
726 const f = Math.sqrt(product); // = ratio over target=1 → divide-by to compensate via two axes
727 if (Math.abs(f - 1) < 0.005) return;
728 if (changedKey === "depthScale") {
729 this.stretchX = clamp(this.stretchX / f, LIMITS.stretchX);
730 this.stretchY = clamp(this.stretchY / f, LIMITS.stretchY);
731 } else if (changedKey === "stretchX") {
732 this.depthScale = clamp(this.depthScale / f, LIMITS.depthScale);
733 this.stretchY = clamp(this.stretchY / f, LIMITS.stretchY);
734 } else if (changedKey === "stretchY") {
735 this.depthScale = clamp(this.depthScale / f, LIMITS.depthScale);
736 this.stretchX = clamp(this.stretchX / f, LIMITS.stretchX);
737 }
738 }
739
740 fart() {
741 this.el.classList.remove("farting");
742 void this.el.offsetWidth;
743 const { svg, durationMs } = randomFartSvg();
744 this.fartEl.innerHTML = svg;
745 this.el.classList.add("farting");
746 setTimeout(() => {
747 this.el.classList.remove("farting");
748 this.fartEl.innerHTML = "";
749 }, durationMs);
750 this.lastMessage = performance.now();
751 }
752
753 jump() {
754 this.el.classList.remove("jumping");
755 void this.el.offsetWidth;
756 this.el.classList.add("jumping");
757 setTimeout(() => this.el.classList.remove("jumping"), 800);
758 this.lastMessage = performance.now();
759 }
760
761 dance() {
762 this.el.classList.remove("dancing");
763 void this.el.offsetWidth;
764 this.el.classList.add("dancing");
765 setTimeout(() => this.el.classList.remove("dancing"), 3050);
766 this.lastMessage = performance.now();
767 }
768
769 roll() {
770 this.el.classList.remove("rolling");
771 void this.el.offsetWidth;
772 this.el.classList.add("rolling");
773 setTimeout(() => this.el.classList.remove("rolling"), 800);
774 this.lastMessage = performance.now();
775 }
776
777 smoke() {
778 this.el.classList.remove("smoking");
779 void this.el.offsetWidth;
780 const { svg, durationMs } = randomSmokeSvg();
781 this.smokeEl.innerHTML = svg;
782 this.el.classList.add("smoking");
783 setTimeout(() => {
784 this.el.classList.remove("smoking");
785 this.smokeEl.innerHTML = "";
786 }, durationMs);
787 this.lastMessage = performance.now();
788 }
789
790 sleep() {
791 this.setState("sleep");
792 this.stateTimer = 0;
793 this.nextDecision = 12 + Math.random() * 12;
794 this.lastMessage = performance.now();
795 saveStateDebounced();
796 }
797
798 teleport() {
799 this.el.classList.remove("teleporting");
800 void this.el.offsetWidth;
801 this.el.classList.add("teleporting");
802 // Move during the invisible middle of the fade
803 setTimeout(() => {
804 this.x = Math.random() * (window.innerWidth - 110);
805 if (Math.random() < 0.5) this.dir *= -1;
806 this.applyTransform();
807 }, 320);
808 setTimeout(() => this.el.classList.remove("teleporting"), 720);
809 this.lastMessage = performance.now();
810 saveStateDebounced();
811 }
812
813 showMessage(rawText) {
814 if (!rawText) return;
815 const safe = redactBadWords(String(rawText)).slice(0, 100).trim();
816 if (!safe) return;
817 this.showingMessage = true;
818 this.labelEl.textContent = safe;
819 this.labelEl.classList.add("chatting");
820 clearTimeout(this._messageHide);
821 this._messageHide = setTimeout(() => {
822 this.showingMessage = false;
823 this.labelEl.classList.remove("chatting");
824 this.labelEl.textContent = this.handle ? "@" + this.handle : "";
825 }, 4500);
826 }
827
828 // !pet @target — show a cartoon hand patting the pet for ~1.8s, plus a heart.
829 petPat() {
830 this.el.classList.remove("petted");
831 void this.el.offsetWidth;
832 this.el.classList.add("petted");
833 this.pulseHeart();
834 setTimeout(() => this.el.classList.remove("petted"), 1900);
835 this.lastMessage = performance.now();
836 }
837
838 pulseHeart() {
839 this.el.classList.remove("happy", "idle", "sleep");
840 void this.el.offsetWidth;
841 this.state = "happy";
842 this.el.classList.add("happy");
843 this.happyUntil = performance.now() + 1200;
844 this.lastMessage = performance.now();
845 }
846
847 tick(dt) {
848 this.stateTimer += dt;
849 const now = performance.now();
850
851 if (this.state === "happy" && now > this.happyUntil) {
852 this.setState("walk");
853 this.stateTimer = 0;
854 }
855
856 if (this.state !== "happy" && this.stateTimer > this.nextDecision) {
857 const r = Math.random();
858 if (this.state === "sleep") {
859 this.setState("walk");
860 } else if (r < 0.6) {
861 this.setState("walk");
862 if (Math.random() < 0.2) this.dir *= -1;
863 } else if (r < 0.88) {
864 this.setState("idle");
865 } else {
866 this.setState("sleep");
867 }
868 this.stateTimer = 0;
869 this.nextDecision = 3 + Math.random() * 5;
870 }
871
872 if (this.state === "walk") {
873 const baseSpeed = this.isHopper ? 70 : this.speed;
874 this.x += this.dir * baseSpeed * this.speedMul * dt;
875 const max = window.innerWidth - 110;
876 if (this.x < 0) {
877 this.x = 0;
878 this.dir = 1;
879 }
880 if (this.x > max) {
881 this.x = max;
882 this.dir = -1;
883 }
884 }
885
886 if (this.mode === "sprite") this.updateSpriteFrame();
887 this.applyTransform();
888 }
889
890 dispose() {
891 const f = document.getElementById(`bulge-${this.filterId}`);
892 if (f) f.remove();
893 this.el.remove();
894 }
895 }
896
897 /* ---------- Profile resolver: handle + PDS + RPG sprite ---------- */
898
899 const profileCache = new Map(); // did -> { handle, pds, sprite }
900 const profileFetchedAt = new Map(); // did -> Date.now() when last fetched
901 const profilePending = new Map(); // did -> Promise
902 const PROFILE_TTL_MS = 60 * 1000;
903
904 function loadImage(url) {
905 return new Promise((resolve, reject) => {
906 const img = new Image();
907 img.onload = () => resolve(img);
908 img.onerror = () => reject(new Error("image load failed: " + url));
909 img.src = url;
910 });
911 }
912
913 async function fetchSprite(pds, did) {
914 const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=actor.rpg.sprite&rkey=self`;
915 const r = await fetch(url);
916 if (!r.ok) return null;
917 const data = await r.json();
918 const rec = data?.value;
919 const cid = rec?.spriteSheet?.ref?.$link;
920 const fw = Number(rec?.frameWidth);
921 const fh = Number(rec?.frameHeight);
922 if (
923 !rec ||
924 !cid ||
925 !isFinite(fw) ||
926 !isFinite(fh) ||
927 fw <= 0 ||
928 fh <= 0
929 )
930 return null;
931 const imageUrl = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${cid}`;
932 let img;
933 try {
934 img = await loadImage(imageUrl);
935 } catch {
936 return null;
937 }
938 // The image must hold at least one frame; otherwise the sprite is unusable.
939 if (img.naturalWidth < fw || img.naturalHeight < fh) {
940 console.warn(
941 "[pets] sprite frame larger than image; falling back to SVG",
942 did,
943 );
944 return null;
945 }
946 // Clamp declared rows/columns to what physically fits in the image. If
947 // someone uploads rows=4 but only 3 rows actually fit, we render 3 rows
948 // instead of refusing the whole sprite — the previous strict check was
949 // rejecting otherwise-fine sprites whose dims were a few pixels off.
950 const maxRows = Math.max(1, Math.floor(img.naturalHeight / fh));
951 const maxCols = Math.max(1, Math.floor(img.naturalWidth / fw));
952 const rows = Math.min(Math.max(1, Number(rec.rows) || 1), maxRows);
953 const columns = Math.min(
954 Math.max(1, Number(rec.columns) || 1),
955 maxCols,
956 );
957 return {
958 imageUrl,
959 rows,
960 columns,
961 frameWidth: fw,
962 frameHeight: fh,
963 width: fw * columns,
964 height: fh * rows,
965 };
966 }
967
968 async function getProfile(did) {
969 const fetchedAt = profileFetchedAt.get(did) || 0;
970 const fresh = Date.now() - fetchedAt < PROFILE_TTL_MS;
971 if (profileCache.has(did) && fresh) return profileCache.get(did);
972 if (profilePending.has(did)) return profilePending.get(did);
973
974 const promise = (async () => {
975 let handle = did;
976 let pds = null;
977 try {
978 const r = await fetch(
979 `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`,
980 );
981 if (r.ok) {
982 const d = await r.json();
983 handle = d.handle || did;
984 pds = d.pds || null;
985 }
986 } catch (e) {
987 console.warn("miniDoc resolve failed", did, e);
988 }
989
990 let sprite = null;
991 if (pds) {
992 try {
993 sprite = await fetchSprite(pds, did);
994 if (sprite) console.log("[pets] rpg sprite found for", handle);
995 } catch (e) {
996 // 404s for users without a sprite are normal — only log other errors
997 if (!String(e).includes("404"))
998 console.warn("sprite fetch error", did, e);
999 }
1000 }
1001
1002 const profile = { handle, pds, sprite };
1003 profileCache.set(did, profile);
1004 profileFetchedAt.set(did, Date.now());
1005 return profile;
1006 })();
1007
1008 profilePending.set(did, promise);
1009 promise.finally(() => profilePending.delete(did));
1010 return promise;
1011 }
1012
1013 function spriteIdentity(s) {
1014 if (!s) return null;
1015 return `${s.imageUrl}|${s.rows}x${s.columns}|${s.frameWidth}x${s.frameHeight}`;
1016 }
1017
1018 /* ---------- Bang-command scene effects ---------- */
1019
1020 const fxState = {
1021 party: false,
1022 snow: false,
1023 rain: false,
1024 confetti: false,
1025 hearts: false,
1026 quake: false,
1027 barrelroll: false,
1028 fireworks: false,
1029 };
1030
1031 function triggerFireworks() {
1032 if (fxState.fireworks) return;
1033 fxState.fireworks = true;
1034 const overlay = document.createElement("div");
1035 overlay.className = "fx-fireworks";
1036 document.body.appendChild(overlay);
1037 const colors = [
1038 "#ff5577",
1039 "#ffd93d",
1040 "#3affc8",
1041 "#ff8e3c",
1042 "#c84fff",
1043 "#3ad4ff",
1044 "#ffffff",
1045 "#ff3a8e",
1046 ];
1047 const burstCount = 7;
1048 for (let b = 0; b < burstCount; b++) {
1049 const cx = 12 + Math.random() * 76;
1050 const cy = 12 + Math.random() * 45;
1051 const color = colors[Math.floor(Math.random() * colors.length)];
1052 const burstDelay = b * (0.4 + Math.random() * 0.7);
1053 const particleCount = 28 + Math.floor(Math.random() * 18);
1054 for (let i = 0; i < particleCount; i++) {
1055 const p = document.createElement("div");
1056 p.className = "firework-particle";
1057 p.style.left = `${cx}vw`;
1058 p.style.top = `${cy}vh`;
1059 p.style.background = color;
1060 const angle = Math.random() * Math.PI * 2;
1061 const distance = 60 + Math.random() * 140;
1062 p.style.setProperty(
1063 "--dx",
1064 `${(Math.cos(angle) * distance).toFixed(1)}px`,
1065 );
1066 p.style.setProperty(
1067 "--dy",
1068 `${(Math.sin(angle) * distance + 80).toFixed(1)}px`,
1069 );
1070 p.style.animationDelay = `${burstDelay.toFixed(2)}s`;
1071 p.style.animationDuration = `${(1.0 + Math.random() * 0.8).toFixed(2)}s`;
1072 overlay.appendChild(p);
1073 }
1074 }
1075 setTimeout(() => {
1076 overlay.remove();
1077 fxState.fireworks = false;
1078 }, 8500);
1079 }
1080
1081 function triggerRain() {
1082 if (fxState.rain) return;
1083 fxState.rain = true;
1084 const overlay = document.createElement("div");
1085 overlay.className = "fx-rain";
1086 document.body.appendChild(overlay);
1087 for (let i = 0; i < 160; i++) {
1088 const drop = document.createElement("div");
1089 drop.className = "raindrop";
1090 drop.style.left = `${Math.random() * 100}%`;
1091 drop.style.height = `${10 + Math.random() * 18}px`;
1092 drop.style.animationDuration = `${0.6 + Math.random() * 0.8}s`;
1093 drop.style.animationDelay = `${Math.random() * 8}s`;
1094 drop.style.opacity = `${0.45 + Math.random() * 0.45}`;
1095 overlay.appendChild(drop);
1096 }
1097 setTimeout(() => {
1098 overlay.remove();
1099 fxState.rain = false;
1100 }, 12000);
1101 }
1102
1103 function triggerParty() {
1104 if (fxState.party) return;
1105 fxState.party = true;
1106 const overlay = document.createElement("div");
1107 overlay.className = "fx-party";
1108 overlay.innerHTML = `
1109 <div class="party-light party-1"></div>
1110 <div class="party-light party-2"></div>
1111 <div class="party-light party-3"></div>
1112 <div class="party-light party-4"></div>`;
1113 document.body.appendChild(overlay);
1114 setTimeout(() => {
1115 overlay.remove();
1116 fxState.party = false;
1117 }, 7800);
1118 }
1119
1120 function triggerSnow() {
1121 if (fxState.snow) return;
1122 fxState.snow = true;
1123 const overlay = document.createElement("div");
1124 overlay.className = "fx-snow";
1125 document.body.appendChild(overlay);
1126 for (let i = 0; i < 90; i++) {
1127 const flake = document.createElement("div");
1128 flake.className = "snowflake";
1129 flake.textContent = "❄";
1130 flake.style.left = `${Math.random() * 100}%`;
1131 flake.style.fontSize = `${8 + Math.random() * 22}px`;
1132 flake.style.animationDuration = `${4 + Math.random() * 7}s`;
1133 flake.style.animationDelay = `${Math.random() * 6}s`;
1134 flake.style.opacity = `${0.5 + Math.random() * 0.5}`;
1135 flake.style.setProperty("--sway", `${(Math.random() - 0.5) * 240}px`);
1136 overlay.appendChild(flake);
1137 }
1138 setTimeout(() => {
1139 overlay.remove();
1140 fxState.snow = false;
1141 }, 14500);
1142 }
1143
1144 function triggerConfetti() {
1145 if (fxState.confetti) return;
1146 fxState.confetti = true;
1147 const colors = [
1148 "#ff5577",
1149 "#5577ff",
1150 "#ffd93d",
1151 "#9bbf2f",
1152 "#ff8e3c",
1153 "#c84fff",
1154 "#3ad4ff",
1155 "#ffffff",
1156 "#ff3a8e",
1157 ];
1158 const overlay = document.createElement("div");
1159 overlay.className = "fx-confetti";
1160 document.body.appendChild(overlay);
1161 for (let i = 0; i < 150; i++) {
1162 const c = document.createElement("div");
1163 c.className = "confetti-piece";
1164 c.style.left = `${Math.random() * 100}%`;
1165 c.style.background =
1166 colors[Math.floor(Math.random() * colors.length)];
1167 c.style.width = `${5 + Math.random() * 7}px`;
1168 c.style.height = `${9 + Math.random() * 13}px`;
1169 c.style.animationDuration = `${2.5 + Math.random() * 3.5}s`;
1170 c.style.animationDelay = `${Math.random() * 1.8}s`;
1171 c.style.setProperty("--sway", `${(Math.random() - 0.5) * 320}px`);
1172 c.style.setProperty(
1173 "--spin",
1174 `${(Math.random() < 0.5 ? -1 : 1) * (360 + Math.random() * 1080)}deg`,
1175 );
1176 overlay.appendChild(c);
1177 }
1178 setTimeout(() => {
1179 overlay.remove();
1180 fxState.confetti = false;
1181 }, 8500);
1182 }
1183
1184 function triggerHearts() {
1185 if (fxState.hearts) return;
1186 fxState.hearts = true;
1187 const overlay = document.createElement("div");
1188 overlay.className = "fx-hearts";
1189 document.body.appendChild(overlay);
1190 const hearts = ["❤", "💖", "💗", "💕", "💓", "🩷"];
1191 for (let i = 0; i < 60; i++) {
1192 const h = document.createElement("div");
1193 h.className = "heart-piece";
1194 h.textContent = hearts[Math.floor(Math.random() * hearts.length)];
1195 h.style.left = `${Math.random() * 100}%`;
1196 h.style.fontSize = `${18 + Math.random() * 28}px`;
1197 h.style.animationDuration = `${3.5 + Math.random() * 3}s`;
1198 h.style.animationDelay = `${Math.random() * 2.5}s`;
1199 h.style.setProperty("--sway", `${(Math.random() - 0.5) * 200}px`);
1200 h.style.setProperty("--end-scale", `${0.8 + Math.random() * 0.6}`);
1201 overlay.appendChild(h);
1202 }
1203 setTimeout(() => {
1204 overlay.remove();
1205 fxState.hearts = false;
1206 }, 7500);
1207 }
1208
1209 function triggerQuake() {
1210 if (fxState.quake) return;
1211 fxState.quake = true;
1212 const stageEl = document.getElementById("stage");
1213 stageEl.classList.add("fx-quake");
1214 setTimeout(() => {
1215 stageEl.classList.remove("fx-quake");
1216 fxState.quake = false;
1217 }, 600);
1218 }
1219
1220 function triggerSpawnStars(x, y) {
1221 const overlay = document.createElement("div");
1222 overlay.className = "fx-spawn-stars";
1223 overlay.style.left = x + "px";
1224 overlay.style.top = y + "px";
1225 document.body.appendChild(overlay);
1226 const emojis = ["✨", "⭐", "🌟", "💫", "⚡", "💥", "🎉", "🔥"];
1227 for (let i = 0; i < 14; i++) {
1228 const angle = Math.random() * Math.PI * 2;
1229 const dist = 30 + Math.random() * 80;
1230 const dx = Math.cos(angle) * dist;
1231 const dy = Math.sin(angle) * dist;
1232 const star = document.createElement("div");
1233 star.className = "star-particle";
1234 star.textContent = emojis[Math.floor(Math.random() * emojis.length)];
1235 star.style.fontSize = `${14 + Math.random() * 22}px`;
1236 star.style.setProperty("--star-dx", dx + "px");
1237 star.style.setProperty("--star-dy", dy + "px");
1238 star.style.animationDuration = 0.5 + Math.random() * 0.6 + "s";
1239 star.style.left = "0px";
1240 star.style.top = "0px";
1241 overlay.appendChild(star);
1242 }
1243 setTimeout(() => overlay.remove(), 1200);
1244 }
1245
1246 /* ---------- Badge checking (repos-based access control) ---------- */
1247
1248 const badgeCheckCache = new Map(); // chatter DID → boolean
1249 let _reposBadgedDids = null; // Promise<Set<string>> | null
1250 let _reposBadgedDidsAt = 0;
1251 const BADGE_TTL_MS = 5 * 60 * 1000;
1252
1253 async function resolveHandleToDid(handle) {
1254 try {
1255 const r = await fetch(
1256 `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`,
1257 );
1258 if (!r.ok) return null;
1259 const d = await r.json();
1260 return d.did || null;
1261 } catch {
1262 return null;
1263 }
1264 }
1265
1266 async function getBadgedDidsForRepo(repoHandle) {
1267 const issuerDid = await resolveHandleToDid(repoHandle);
1268 if (!issuerDid) return new Set();
1269 const profile = await getProfile(issuerDid);
1270 const pds = profile?.pds;
1271 if (!pds) return new Set();
1272 const out = new Set();
1273 let cursor = "";
1274 for (let page = 0; page < 5; page++) {
1275 const url =
1276 `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.listRecords` +
1277 `?repo=${encodeURIComponent(issuerDid)}` +
1278 `&collection=place.stream.badge.issuance&limit=100` +
1279 (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
1280 let data;
1281 try {
1282 const r = await fetch(url);
1283 if (!r.ok) break;
1284 data = await r.json();
1285 } catch {
1286 break;
1287 }
1288 for (const rec of data.records || []) {
1289 if (
1290 rec.value &&
1291 typeof rec.value.did === "string" &&
1292 rec.value.did.startsWith("did:")
1293 ) {
1294 out.add(rec.value.did);
1295 }
1296 }
1297 cursor = data.cursor || "";
1298 if (!cursor) break;
1299 }
1300 return out;
1301 }
1302
1303 async function getAllBadgedDids() {
1304 if (_reposBadgedDids && Date.now() - _reposBadgedDidsAt < BADGE_TTL_MS)
1305 return _reposBadgedDids;
1306 const promise = (async () => {
1307 const all = new Set();
1308 if (!REPOS || REPOS.length === 0) return all;
1309 for (const repo of REPOS) {
1310 const set = await getBadgedDidsForRepo(repo.trim());
1311 for (const did of set) all.add(did);
1312 }
1313 console.log(
1314 `[pets] badged DIDs across ${REPOS.length} repos: ${all.size}`,
1315 );
1316 return all;
1317 })();
1318 _reposBadgedDids = promise;
1319 _reposBadgedDidsAt = Date.now();
1320 return promise;
1321 }
1322
1323 async function chatterIsBadged(did) {
1324 if (STREAMER_DID && did === STREAMER_DID) return true;
1325 if (!REPOS || REPOS.length === 0) return true;
1326 if (badgeCheckCache.has(did)) return badgeCheckCache.get(did);
1327 const badged = await getAllBadgedDids();
1328 const ok = badged.has(did);
1329 if (ok) badgeCheckCache.set(did, true);
1330 return ok;
1331 }
1332
1333 /* ---------- Streamer-only and badge-gated actions ---------- */
1334
1335 function triggerBarrelRoll() {
1336 if (fxState.barrelroll) return;
1337 fxState.barrelroll = true;
1338 document.body.classList.add("fx-barrelroll");
1339 setTimeout(() => {
1340 document.body.classList.remove("fx-barrelroll");
1341 fxState.barrelroll = false;
1342 }, 1450);
1343 }
1344
1345 async function triggerRefresh(chatterDid) {
1346 const allowed = await chatterIsBadged(chatterDid);
1347 if (!allowed) {
1348 console.log(
1349 "[pets] !refresh denied — chatter lacks required badge",
1350 chatterDid,
1351 );
1352 return;
1353 }
1354 saveState();
1355 setTimeout(() => location.reload(), 200);
1356 }
1357
1358 async function triggerClearAll(chatterDid) {
1359 const allowed = await chatterIsBadged(chatterDid);
1360 if (!allowed) {
1361 console.log(
1362 "[pets] !clearall denied — chatter lacks required badge",
1363 chatterDid,
1364 );
1365 return;
1366 }
1367 try {
1368 localStorage.clear();
1369 } catch {}
1370 _suppressSave = true;
1371 setTimeout(() => location.reload(), 200);
1372 }
1373
1374 async function triggerHardReset(chatterDid) {
1375 if (!STREAMER_DID || chatterDid !== STREAMER_DID) {
1376 console.log(
1377 "[pets] !hardreset denied — only the streamer can run this",
1378 chatterDid,
1379 );
1380 return;
1381 }
1382 try {
1383 localStorage.clear();
1384 } catch {}
1385 try {
1386 sessionStorage.clear();
1387 } catch {}
1388 try {
1389 if (window.caches) {
1390 const keys = await caches.keys();
1391 await Promise.all(keys.map((k) => caches.delete(k)));
1392 }
1393 } catch {}
1394 try {
1395 if (indexedDB.databases) {
1396 const dbs = await indexedDB.databases();
1397 await Promise.all(
1398 dbs.map(
1399 (d) =>
1400 d.name &&
1401 new Promise((res) => {
1402 const req = indexedDB.deleteDatabase(d.name);
1403 req.onsuccess = req.onerror = req.onblocked = () => res();
1404 }),
1405 ),
1406 );
1407 }
1408 } catch {}
1409 _suppressSave = true;
1410 setTimeout(() => {
1411 const u = new URL(location.href);
1412 u.searchParams.set("_t", String(Date.now()));
1413 location.replace(u.toString());
1414 }, 250);
1415 }
1416
1417 /* ---------- Persistence (so refresh doesn't lose state) ---------- */
1418
1419 const STATE_KEY = "streampets-state-v1";
1420 const STATE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
1421
1422 let _saveStateTimer = null;
1423 let _suppressSave = false;
1424 function saveStateDebounced() {
1425 clearTimeout(_saveStateTimer);
1426 _saveStateTimer = setTimeout(saveState, 1500);
1427 }
1428
1429 function saveState() {
1430 if (_suppressSave) return;
1431 try {
1432 const arr = [];
1433 const nowPerf = performance.now();
1434 const wallNow = Date.now();
1435 for (const [did, pet] of pets) {
1436 arr.push({
1437 did,
1438 handle: pet.handle,
1439 x: pet.x,
1440 dir: pet.dir,
1441 depthScale: pet.depthScale,
1442 stretchX: pet.stretchX,
1443 stretchY: pet.stretchY,
1444 speedMul: pet.speedMul,
1445 bulgeLevel: pet.bulgeLevel,
1446 blurLevel: pet.blurLevel,
1447 typeOverride: petTypeOverrides.get(did) || null,
1448 lastMessageWall: wallNow - (nowPerf - pet.lastMessage),
1449 sprite: pet.mode === "sprite" ? pet.sprite : null,
1450 });
1451 }
1452 localStorage.setItem(
1453 STATE_KEY,
1454 JSON.stringify({ ts: wallNow, pets: arr }),
1455 );
1456 } catch (e) {
1457 /* localStorage may be disabled */
1458 }
1459 }
1460
1461 function restoreState() {
1462 try {
1463 const raw = localStorage.getItem(STATE_KEY);
1464 if (!raw) return;
1465 const data = JSON.parse(raw);
1466 if (!data.pets || Date.now() - data.ts > STATE_TTL_MS) return;
1467 for (const saved of data.pets) {
1468 if (pets.has(saved.did)) continue;
1469 if (saved.typeOverride)
1470 petTypeOverrides.set(saved.did, saved.typeOverride);
1471 let spec;
1472 if (saved.typeOverride) {
1473 spec = pickPet(saved.did, saved.handle, null);
1474 } else if (saved.sprite) {
1475 spec = {
1476 mode: "sprite",
1477 sprite: saved.sprite,
1478 scale: 1.0,
1479 speed: 42,
1480 };
1481 } else {
1482 spec = pickPet(saved.did, saved.handle, null);
1483 }
1484 const pet = new Pet(
1485 spec,
1486 document.getElementById("stage"),
1487 saved.handle,
1488 );
1489 pet.x = typeof saved.x === "number" ? saved.x : pet.x;
1490 pet.dir = saved.dir === -1 ? -1 : 1;
1491 pet.depthScale = saved.depthScale || 1;
1492 pet.stretchX = saved.stretchX || 1;
1493 pet.stretchY = saved.stretchY || 1;
1494 pet.speedMul = saved.speedMul || 1;
1495 pet.el.style.setProperty("--anim-speed", pet.speedMul);
1496 if (saved.bulgeLevel) pet.setBulgeLevel(saved.bulgeLevel);
1497 if (saved.blurLevel) pet.setBlurLevel(saved.blurLevel);
1498 pet.applyTransform();
1499 pet.el.classList.remove("spawning");
1500 pets.set(saved.did, pet);
1501 // Seed profile cache so subsequent messages don't re-resolve
1502 profileCache.set(saved.did, {
1503 handle: saved.handle,
1504 pds: null,
1505 sprite: saved.sprite || null,
1506 });
1507 }
1508 console.log(
1509 `[pets] restored ${data.pets.length} pets from localStorage`,
1510 );
1511 } catch (e) {
1512 console.warn("restoreState failed", e);
1513 }
1514 }
1515
1516 window.addEventListener("beforeunload", saveState);
1517
1518 /* ---------- Pet manager ---------- */
1519
1520 const stage = document.getElementById("stage");
1521 const statusEl = document.getElementById("status");
1522 const pets = new Map();
1523 const commandCooldownUntil = new Map(); // did → performance.now() until which commands are blocked
1524 const MAX_PETS = 30;
1525
1526 function evictIfNeeded() {
1527 if (pets.size < MAX_PETS) return;
1528 let oldestDid = null;
1529 let oldestTime = Infinity;
1530 for (const [did, pet] of pets) {
1531 if (pet.lastMessage < oldestTime) {
1532 oldestTime = pet.lastMessage;
1533 oldestDid = did;
1534 }
1535 }
1536 if (oldestDid) {
1537 pets.get(oldestDid).dispose();
1538 pets.delete(oldestDid);
1539 }
1540 }
1541
1542 async function handleChatMessage(authorDid, record) {
1543 const text = String(record.text || "")
1544 .trim()
1545 .toLowerCase();
1546
1547 // `reload` / `refresh` — drop cached profile + pet so a freshly uploaded sprite
1548 // re-resolves on the next handler call.
1549 if (
1550 /^(reload|reloadsprite|reload\s*sprite|refresh|respawn)$/i.test(text)
1551 ) {
1552 profileCache.delete(authorDid);
1553 profileFetchedAt.delete(authorDid);
1554 badgeCheckCache.delete(authorDid);
1555 if (pets.has(authorDid)) {
1556 pets.get(authorDid).dispose();
1557 pets.delete(authorDid);
1558 }
1559 console.log("[pets] reloaded", authorDid);
1560 }
1561
1562 // `reload @other-handle` — drop another chatter's profile + pet so their newly
1563 // uploaded sprite resolves on their next message. Mod-only (badge-gated) per
1564 // streamer feedback: actions that affect a different user need permission.
1565 const reloadOtherMatch = (record.text || "").match(
1566 /^\s*(?:reload|reloadsprite|reload\s*sprite|refresh|respawn)\s+(?:rpg\.actor\s+sprite\s+for\s+)?@?([A-Za-z0-9\.\-_]+)\s*$/i,
1567 );
1568 if (
1569 reloadOtherMatch &&
1570 reloadOtherMatch[1].toLowerCase() !== "sprite"
1571 ) {
1572 if (await chatterIsBadged(authorDid)) {
1573 const target = reloadOtherMatch[1].toLowerCase();
1574 for (const [did, p] of pets) {
1575 const h = (p.handle || "").toLowerCase();
1576 if (
1577 h === target ||
1578 h.split(".")[0] === target ||
1579 h.startsWith(target + ".")
1580 ) {
1581 profileCache.delete(did);
1582 profileFetchedAt.delete(did);
1583 p.dispose();
1584 pets.delete(did);
1585 console.log("[pets] reloaded target", did, "@" + h);
1586 break;
1587 }
1588 }
1589 } else {
1590 console.log(
1591 "[pets] reload @target denied — chatter has no badge",
1592 authorDid,
1593 );
1594 }
1595 }
1596
1597 const profile = await getProfile(authorDid);
1598
1599 let pet = pets.get(authorDid);
1600 // If the user's sprite identity changed (uploaded a new one, fixed a broken
1601 // one, removed it), drop the old pet and rebuild from the new spec.
1602 if (pet) {
1603 const oldId =
1604 pet.mode === "sprite" ? spriteIdentity(pet.sprite) : null;
1605 const newId = spriteIdentity(profile.sprite);
1606 if (oldId !== newId) {
1607 pet.dispose();
1608 pets.delete(authorDid);
1609 pet = null;
1610 }
1611 }
1612 if (!pet) {
1613 evictIfNeeded();
1614 const spec = pickPet(authorDid, profile.handle, profile.sprite);
1615 pet = new Pet(spec, stage, profile.handle);
1616 pets.set(authorDid, pet);
1617 triggerSpawnStars(
1618 Math.max(10, Math.min(window.innerWidth - 10, pet.x)),
1619 window.innerHeight - 90,
1620 );
1621 } else {
1622 pet.setHandle(profile.handle);
1623 }
1624
1625 const CMD = {
1626 // Aliases: en + de + nl
1627 left: new Set(["left", "links", "<", "⬅️"]),
1628 right: new Set(["right", "rechts", ">", "➡️"]),
1629 up: new Set([
1630 "up",
1631 "hoch",
1632 "omhoog",
1633 "boven",
1634 "smaller",
1635 "kleiner",
1636 "^",
1637 "⬆️",
1638 "🔼",
1639 ]),
1640 down: new Set([
1641 "down",
1642 "runter",
1643 "omlaag",
1644 "beneden",
1645 "bigger",
1646 "größer",
1647 "grösser",
1648 "groter",
1649 "v",
1650 "⬇️",
1651 "🔽",
1652 ]),
1653 faster: new Set([
1654 "speedup",
1655 "fast",
1656 "faster",
1657 "run",
1658 "schneller",
1659 "sneller",
1660 "vlugger",
1661 "⏩",
1662 ]),
1663 slower: new Set([
1664 "slowdown",
1665 "slow",
1666 "slower",
1667 "langsamer",
1668 "langzamer",
1669 "langzaam",
1670 "traag",
1671 "⏪",
1672 ]),
1673 jump: new Set(["jump", "spring", "springen", "springt", "hop", "🦘"]),
1674 dance: new Set([
1675 "dance",
1676 "tanz",
1677 "tanze",
1678 "tanzen",
1679 "dans",
1680 "dansen",
1681 "💃",
1682 "🕺",
1683 ]),
1684 fart: new Set([
1685 "fart",
1686 "farts",
1687 "farted",
1688 "farting",
1689 "scheet",
1690 "scheten",
1691 "furz",
1692 "💨",
1693 ]),
1694 roll: new Set([
1695 "roll",
1696 "rolls",
1697 "barrelroll",
1698 "rolle",
1699 "rollen",
1700 "spin",
1701 "🌀",
1702 ]),
1703 teleport: new Set(["teleport", "tp", "warp", "beam", "✨"]),
1704 sleep: new Set([
1705 "sleep",
1706 "sleeping",
1707 "snooze",
1708 "rest",
1709 "schlafen",
1710 "schlaf",
1711 "slapen",
1712 "slaap",
1713 "😴",
1714 "💤",
1715 ]),
1716 smoke: new Set([
1717 "smoke",
1718 "smoking",
1719 "cig",
1720 "cigarette",
1721 "rauch",
1722 "rauchen",
1723 "rook",
1724 "roken",
1725 "🚬",
1726 ]),
1727 wider: new Set([
1728 "wide",
1729 "wider",
1730 "breit",
1731 "breiter",
1732 "breed",
1733 "breder",
1734 "wijd",
1735 "wijder",
1736 "stretch",
1737 ]),
1738 narrower: new Set([
1739 "narrow",
1740 "narrower",
1741 "schmal",
1742 "schmaler",
1743 "smal",
1744 "thin",
1745 "dun",
1746 ]),
1747 taller: new Set([
1748 "tall",
1749 "taller",
1750 "lang",
1751 "länger",
1752 "laenger",
1753 "hoog",
1754 "hoger",
1755 ]),
1756 shorter: new Set([
1757 "short",
1758 "shorter",
1759 "flach",
1760 "flacher",
1761 "kurz",
1762 "kürzer",
1763 "kort",
1764 "korter",
1765 "laag",
1766 "lager",
1767 ]),
1768 bulge: new Set([
1769 "bulge",
1770 "bulgy",
1771 "fisheye",
1772 "fisch",
1773 "bulle",
1774 "bolle",
1775 "bol",
1776 ]),
1777 unbulge: new Set(["unbulge", "debulge", "flatten", "unfish"]),
1778 blur: new Set(["blur", "blurry", "unsharp", "verschwommen", "wazig"]),
1779 unblur: new Set([
1780 "unblur",
1781 "sharp",
1782 "sharpen",
1783 "focus",
1784 "scharp",
1785 "scherp",
1786 ]),
1787 };
1788
1789 // Temporarily replace the handle pill with the redacted chat message
1790 pet.showMessage(record.text || "");
1791
1792 // Pet-type pick: must be the *whole* message (optionally with a leading !).
1793 // Casual mentions ("did you turn me into a duck") used to trigger this and
1794 // accidentally morph the speaker — that was a footgun, hence the strict match.
1795 // 'random' (also whole-message-only) clears the override.
1796 const TYPE_WORDS = {
1797 cat: "cat",
1798 kitty: "cat",
1799 kitten: "cat",
1800 katze: "cat",
1801 kat: "cat",
1802 dog: "dog",
1803 doggo: "dog",
1804 puppy: "dog",
1805 hund: "dog",
1806 hond: "dog",
1807 bunny: "bunny",
1808 rabbit: "bunny",
1809 hase: "bunny",
1810 konijn: "bunny",
1811 duck: "duck",
1812 ente: "duck",
1813 eend: "duck",
1814 hamster: "hamster",
1815 goose: "goose",
1816 gans: "goose",
1817 };
1818 const typeMatch = text.match(/^\s*!?([a-zäöü]+)\s*$/i);
1819 let chosenType = null;
1820 if (typeMatch) {
1821 const w = typeMatch[1].toLowerCase();
1822 if (TYPE_WORDS[w]) chosenType = TYPE_WORDS[w];
1823 else if (w === "random") {
1824 petTypeOverrides.delete(authorDid);
1825 chosenType = "__rebuild__";
1826 }
1827 }
1828 if (chosenType) {
1829 if (chosenType !== "__rebuild__")
1830 petTypeOverrides.set(authorDid, chosenType);
1831 const oldX = pet.x,
1832 oldDir = pet.dir;
1833 const oldSpeedMul = pet.speedMul,
1834 oldDepthScale = pet.depthScale;
1835 const oldStretchX = pet.stretchX,
1836 oldStretchY = pet.stretchY;
1837 const oldBulge = pet.bulgeLevel;
1838 const oldBlur = pet.blurLevel;
1839 pet.dispose();
1840 pets.delete(authorDid);
1841 const profileNow = profileCache.get(authorDid) || profile;
1842 const spec = pickPet(authorDid, profileNow.handle, profileNow.sprite);
1843 pet = new Pet(spec, stage, profileNow.handle);
1844 pet.x = oldX;
1845 pet.dir = oldDir;
1846 pet.speedMul = oldSpeedMul;
1847 pet.depthScale = oldDepthScale;
1848 pet.stretchX = oldStretchX;
1849 pet.stretchY = oldStretchY;
1850 pet.el.style.setProperty("--anim-speed", pet.speedMul);
1851 if (oldBulge) pet.setBulgeLevel(oldBulge);
1852 if (oldBlur) pet.setBlurLevel(oldBlur);
1853 pet.applyTransform();
1854 pets.set(authorDid, pet);
1855 triggerSpawnStars(
1856 Math.max(10, Math.min(window.innerWidth - 10, pet.x)),
1857 window.innerHeight - 90,
1858 );
1859 saveStateDebounced();
1860 }
1861
1862 // Per-chatter command rate limit — keeps one person from monopolizing the scene.
1863 // Plain chat (no command) still pulses the heart, no cooldown.
1864 // Recovery commands (!reset, !refresh) bypass the limit so users can always
1865 // dig themselves out of an effects pileup.
1866 const RECOVERY_CMD_RE =
1867 /(!reset|!refresh|!clearall|!factoryreset|!hardreset)\b/i;
1868 if (RECOVERY_CMD_RE.test(text)) {
1869 if (/!reset\b/i.test(text)) pet.resetAll();
1870 if (/!refresh\b/i.test(text)) triggerRefresh(authorDid);
1871 if (/!(clearall|factoryreset)\b/i.test(text))
1872 triggerClearAll(authorDid);
1873 if (/!hardreset\b/i.test(text)) triggerHardReset(authorDid);
1874 return;
1875 }
1876 const ALL_COMMAND_RE =
1877 /(!party|!snow|!rain|!confetti|!hearts?|!(?:quake|earthquake|shake)|!fireworks?|!shrink(?:ray)?|!grow(?:ray)?|!pet\b|barrel\s*roll)\b/i;
1878 const looksLikeCommand =
1879 ALL_COMMAND_RE.test(text) ||
1880 /\b(left|right|up|down|fast|faster|slow|slower|jump|dance|fart|wide|narrow|tall|short|stretch|hop|bulge|bulgy|fisheye|unbulge|flatten|debulge|unfish|blur|unblur|roll|spin|teleport|warp|beam|sleep|sleeping|snooze|smoke|smoking|cig|cigarette)/i.test(
1881 text,
1882 ) ||
1883 /\b(links|rechts|hoch|runter|größer|grösser|kleiner|schneller|langsamer|spring|tanz|scheet|scheten|furz|breit|schmal|kurz|lang|rolle|rollen|schlafen|schlaf|rauch|rauchen)/i.test(
1884 text,
1885 ) ||
1886 /\b(omhoog|omlaag|sneller|langzamer|dans|dansen|wijd|smal|hoger|laag|lager|breed|slapen|slaap|rook|roken)/i.test(
1887 text,
1888 ) ||
1889 /(⬅️|➡️|⬆️|⬇️|🔼|🔽|⏩|⏪|🦘|💃|🕺|💨|🌀|✨|😴|💤|🚬)/.test(text);
1890 const COMMAND_COOLDOWN_MS = 10000; // 10 seconds
1891 const now = performance.now();
1892 if (looksLikeCommand) {
1893 const until = commandCooldownUntil.get(authorDid) || 0;
1894 if (now < until) {
1895 console.log(
1896 "[pets] rate limited",
1897 authorDid,
1898 `(${Math.round(until - now)}ms remaining)`,
1899 );
1900 // Visual indicator: red glow for 1 second
1901 pet.el.classList.add("rate-limited");
1902 setTimeout(() => pet.el.classList.remove("rate-limited"), 1000);
1903 return;
1904 }
1905 commandCooldownUntil.set(authorDid, now + COMMAND_COOLDOWN_MS);
1906 }
1907
1908 // !pet @target — friendly pat on the head, no badge required.
1909 const petMatch = text.match(/!pet\s+@?([\w\.\-]+)/i);
1910 if (petMatch) {
1911 const target = petMatch[1].toLowerCase();
1912 for (const p of pets.values()) {
1913 const h = (p.handle || "").toLowerCase();
1914 if (
1915 h === target ||
1916 h.split(".")[0] === target ||
1917 h.startsWith(target + ".")
1918 ) {
1919 p.petPat();
1920 break;
1921 }
1922 }
1923 }
1924
1925 // !shrink @target / !grow @target — affect another chatter's pet by handle.
1926 // Mod-only (badge-gated) per streamer: cross-user commands need mod perms.
1927 const shrinkRayMatch = text.match(
1928 /!(shrink|shrinkray|grow|growray)\s+@?([\w\.\-]+)/i,
1929 );
1930 if (shrinkRayMatch) {
1931 if (await chatterIsBadged(authorDid)) {
1932 const isShrink = /^shrink/i.test(shrinkRayMatch[1]);
1933 const target = shrinkRayMatch[2].toLowerCase();
1934 let foundPet = null;
1935 for (const p of pets.values()) {
1936 const h = (p.handle || "").toLowerCase();
1937 if (
1938 h === target ||
1939 h.split(".")[0] === target ||
1940 h.startsWith(target + ".")
1941 ) {
1942 foundPet = p;
1943 break;
1944 }
1945 }
1946 if (foundPet) {
1947 foundPet.setDepthScale(
1948 foundPet.depthScale * (isShrink ? 1 / 1.5 : 1.5),
1949 );
1950 foundPet.pulseHeart && foundPet.pulseHeart();
1951 } else {
1952 console.log("[pets] !shrink/!grow target not on stage:", target);
1953 }
1954 } else {
1955 console.log(
1956 "[pets] !shrink/!grow denied — chatter has no badge",
1957 authorDid,
1958 );
1959 }
1960 }
1961
1962 // === Mid-tier: bang commands (badge-gated if repos configured) ===
1963 const bangCmds = [];
1964 if (/!party\b/i.test(text)) bangCmds.push(["party", triggerParty]);
1965 if (/!snow\b/i.test(text)) bangCmds.push(["snow", triggerSnow]);
1966 if (/!rain\b/i.test(text)) bangCmds.push(["rain", triggerRain]);
1967 if (/!confetti\b/i.test(text))
1968 bangCmds.push(["confetti", triggerConfetti]);
1969 if (/!hearts?\b/i.test(text)) bangCmds.push(["hearts", triggerHearts]);
1970 if (/!(quake|earthquake|shake)\b/i.test(text))
1971 bangCmds.push(["quake", triggerQuake]);
1972 if (/\bbarrel\s*roll\b/i.test(text))
1973 bangCmds.push(["barrelroll", triggerBarrelRoll]);
1974 if (/!fireworks?\b/i.test(text))
1975 bangCmds.push(["fireworks", triggerFireworks]);
1976
1977 if (bangCmds.length > 0) {
1978 const allowed = await chatterIsBadged(authorDid);
1979 if (!allowed) {
1980 console.log(
1981 "[pets] bang command denied — chatter lacks required badge",
1982 authorDid,
1983 );
1984 } else {
1985 for (const [, fn] of bangCmds) fn();
1986 }
1987 }
1988
1989 // Tokenize: split on whitespace, strip ASCII edge-punctuation
1990 const tokens = text
1991 .split(/\s+/)
1992 .map((t) =>
1993 t.replace(/^[!?.,;:'"()\[\]{}*]+|[!?.,;:'"()\[\]{}*]+$/g, ""),
1994 );
1995 const counts = {};
1996 for (let i = 0; i < tokens.length; i++) {
1997 const rawTok = tokens[i];
1998 if (!rawTok) continue;
1999 const m = rawTok.match(/^(.+?)x(\d+)$/);
2000 const word = m ? m[1] : rawTok;
2001 let repeat = m
2002 ? Math.max(1, Math.min(LIMITS.xN, parseInt(m[2], 10)))
2003 : 1;
2004 // Look-ahead: if this token isn't already a multiplier and the next token is a pure number, use it.
2005 if (!m && i + 1 < tokens.length && /^x?\d+$/i.test(tokens[i + 1])) {
2006 const n = parseInt(tokens[i + 1].replace(/^x/i, ""), 10);
2007 if (n > 0) {
2008 repeat = Math.min(LIMITS.xN, n);
2009 i++; // consume the number token
2010 }
2011 }
2012 let cmd = null;
2013 for (const [k, set] of Object.entries(CMD)) {
2014 if (set.has(word)) {
2015 cmd = k;
2016 break;
2017 }
2018 }
2019 if (!cmd) continue;
2020 counts[cmd] = (counts[cmd] || 0) + repeat;
2021 }
2022 // Animation commands stay reasonable to avoid browser pain; state commands flow through to setters which clamp themselves.
2023 if (counts.jump) counts.jump = Math.min(LIMITS.jumpQueue, counts.jump);
2024 if (counts.dance) counts.dance = Math.min(1, counts.dance);
2025 if (counts.fart) counts.fart = Math.min(LIMITS.fartQueue, counts.fart);
2026
2027 // Apply
2028 if (counts.left) pet.setDirection(-1);
2029 if (counts.right) pet.setDirection(1);
2030 if (counts.up)
2031 pet.setDepthScale(pet.depthScale / Math.pow(1.25, counts.up));
2032 if (counts.down)
2033 pet.setDepthScale(pet.depthScale * Math.pow(1.25, counts.down));
2034 if (counts.faster)
2035 pet.setSpeedMul(pet.speedMul * Math.pow(1.5, counts.faster));
2036 if (counts.slower)
2037 pet.setSpeedMul(pet.speedMul / Math.pow(1.5, counts.slower));
2038 if (counts.jump) {
2039 for (let i = 0; i < counts.jump; i++)
2040 setTimeout(() => pet.jump(), i * 850);
2041 }
2042 if (counts.dance) pet.dance();
2043 if (counts.fart) {
2044 for (let i = 0; i < counts.fart; i++)
2045 setTimeout(() => pet.fart(), i * 600);
2046 }
2047 if (counts.roll) {
2048 for (let i = 0; i < counts.roll; i++)
2049 setTimeout(() => pet.roll(), i * 800);
2050 }
2051 if (counts.teleport) pet.teleport();
2052 if (counts.sleep) pet.sleep();
2053 if (counts.smoke) pet.smoke();
2054 if (counts.wider)
2055 pet.setStretchX(pet.stretchX * Math.pow(1.25, counts.wider));
2056 if (counts.narrower)
2057 pet.setStretchX(pet.stretchX / Math.pow(1.25, counts.narrower));
2058 if (counts.taller)
2059 pet.setStretchY(pet.stretchY * Math.pow(1.25, counts.taller));
2060 if (counts.shorter)
2061 pet.setStretchY(pet.stretchY / Math.pow(1.25, counts.shorter));
2062 if (counts.bulge) pet.setBulgeLevel(pet.bulgeLevel + counts.bulge);
2063 if (counts.unbulge) pet.setBulgeLevel(0);
2064 if (counts.blur) pet.setBlurLevel(pet.blurLevel + counts.blur);
2065 if (counts.unblur) pet.setBlurLevel(0);
2066
2067 // Catch-all: substring "fart" inside a sentence still fires once if no fart token matched.
2068 if (!counts.fart && (/\bfart/i.test(text) || text.includes("💨"))) {
2069 pet.fart();
2070 counts.fart = 1;
2071 }
2072
2073 // (No more pulseHeart on plain chat — the message bubble now carries the
2074 // "I said something" signal and the heart was visual noise.)
2075 }
2076
2077 /* ---------- Jetstream ---------- */
2078
2079 const params = new URLSearchParams(location.search);
2080 const STREAMER_DID = params.get("streamer");
2081 const REPOS = params.get("repos")
2082 ? params
2083 .get("repos")
2084 .split(",")
2085 .map((s) => s.trim())
2086 .filter(Boolean)
2087 : null;
2088 const SHOW_STATUS = params.get("status") === "1";
2089 const SAFE_LIMITS =
2090 params.get("limits") === "safe" || params.get("safe") === "1";
2091 const CONSERVATION = params.get("conservation") === "1";
2092
2093 const LIMITS = SAFE_LIMITS
2094 ? {
2095 speedMul: [0.2, 5],
2096 depthScale: [0.3, 2.2],
2097 stretchX: [0.4, 3.5],
2098 stretchY: [0.4, 3.5],
2099 bulgeLevel: [0, 8],
2100 blurLevel: [0, 6],
2101 xN: 10,
2102 jumpQueue: 5,
2103 fartQueue: 5,
2104 }
2105 : {
2106 speedMul: [0.02, 50],
2107 depthScale: [0.005, 80],
2108 stretchX: [0.005, 80],
2109 stretchY: [0.005, 80],
2110 bulgeLevel: [0, 30],
2111 blurLevel: [0, 20],
2112 xN: 100,
2113 jumpQueue: 50,
2114 fartQueue: 50,
2115 };
2116
2117 if (SHOW_STATUS) statusEl.classList.remove("hidden");
2118
2119 function setStatus(s) {
2120 if (SHOW_STATUS) statusEl.textContent = s;
2121 console.log("[pets]", s);
2122 }
2123
2124 const JETSTREAM_HOSTS = [
2125 "jetstream2.us-east.bsky.network",
2126 "jetstream1.us-east.bsky.network",
2127 "jetstream2.us-west.bsky.network",
2128 "jetstream1.us-west.bsky.network",
2129 ];
2130 let hostIdx = 0;
2131 let backoff = 1000;
2132
2133 function connectJetstream() {
2134 const host = JETSTREAM_HOSTS[hostIdx % JETSTREAM_HOSTS.length];
2135 const url = `wss://${host}/subscribe?wantedCollections=place.stream.chat.message`;
2136 setStatus(`connecting → ${host}`);
2137 let ws;
2138 try {
2139 ws = new WebSocket(url);
2140 } catch (e) {
2141 console.warn("ws construct error", e);
2142 scheduleReconnect();
2143 return;
2144 }
2145
2146 ws.onopen = () => {
2147 setStatus(
2148 `live · ${host}${STREAMER_DID ? " · filter on" : " · all streams"}${REPOS ? " · repos=" + REPOS.join(",") : ""}`,
2149 );
2150 backoff = 1000;
2151 };
2152
2153 ws.onmessage = (ev) => {
2154 let msg;
2155 try {
2156 msg = JSON.parse(ev.data);
2157 } catch {
2158 return;
2159 }
2160 if (msg.kind !== "commit") return;
2161 const c = msg.commit;
2162 if (!c || c.operation !== "create") return;
2163 if (c.collection !== "place.stream.chat.message") return;
2164 const record = c.record;
2165 if (!record || typeof record !== "object") return;
2166 if (STREAMER_DID && record.streamer !== STREAMER_DID) return;
2167 handleChatMessage(msg.did, record);
2168 };
2169
2170 ws.onerror = (e) => console.warn("jetstream error", e);
2171
2172 ws.onclose = () => {
2173 setStatus("disconnected");
2174 hostIdx++;
2175 scheduleReconnect();
2176 };
2177 }
2178
2179 function scheduleReconnect() {
2180 setTimeout(connectJetstream, backoff);
2181 backoff = Math.min(backoff * 1.5, 15000);
2182 }
2183
2184 // Build version: bumped each commit so chat can see new code arrived.
2185 const BUILD_VERSION = "v0.9.0";
2186 const BUILD_DATE = "2025-07-14";
2187 const versionEl = document.getElementById("version");
2188 if (STREAMER_DID) {
2189 versionEl.textContent = `pets ${BUILD_VERSION} · ${BUILD_DATE}`;
2190 } else {
2191 // Visible warning so the streamer notices the filter is off — without a
2192 // ?streamer=did:... URL param, every chat from every stream lands on stage.
2193 versionEl.textContent = `pets ${BUILD_VERSION} · NO STREAMER FILTER — add ?streamer=did:... to the URL`;
2194 versionEl.style.color = "rgba(255, 180, 80, 0.95)";
2195 versionEl.style.background = "rgba(0, 0, 0, 0.55)";
2196 versionEl.style.padding = "2px 6px";
2197 versionEl.style.borderRadius = "4px";
2198 }
2199
2200 // "Reloaded" toast — pops up briefly when the page (re)loads.
2201 {
2202 const toast = document.createElement("div");
2203 toast.id = "reload-toast";
2204 toast.textContent = `pets reloaded · ${BUILD_VERSION}`;
2205 document.body.appendChild(toast);
2206 setTimeout(() => toast.remove(), 4000);
2207 }
2208
2209 // Hard-reset button — clears everything and reloads
2210 document.getElementById('reset-btn').addEventListener('click', async () => {
2211 if (!confirm('Hard reset? This clears all pet state, sprites, caches, and reloads.')) return;
2212 try { localStorage.clear(); } catch {}
2213 try { sessionStorage.clear(); } catch {}
2214 try {
2215 if (window.caches) {
2216 const keys = await caches.keys();
2217 await Promise.all(keys.map((k) => caches.delete(k)));
2218 }
2219 } catch {}
2220 try {
2221 if (indexedDB.databases) {
2222 const dbs = await indexedDB.databases();
2223 await Promise.all(dbs.map((d) => d.name && new Promise((res) => {
2224 const req = indexedDB.deleteDatabase(d.name);
2225 req.onsuccess = req.onerror = req.onblocked = () => res();
2226 })));
2227 }
2228 } catch {}
2229 _suppressSave = true;
2230 location.reload();
2231 });
2232
2233 restoreState();
2234 connectJetstream();
2235
2236 /* ---------- Main loop ---------- */
2237
2238 // Three-stage idle fade:
2239 // 0 – 10 min: full opacity
2240 // 10 – 20 min: opacity 0.5
2241 // 20 – 30 min: opacity 0.25
2242 // > 30 min: removed
2243 // Messages immediately restore full opacity.
2244 const IDLE_FADE_1_MS = 10 * 60 * 1000; // 50% opacity at 10 min
2245 const IDLE_FADE_2_MS = 20 * 60 * 1000; // 25% opacity at 20 min
2246 const IDLE_DEATH_MS = 30 * 60 * 1000; // removed at 30 min
2247
2248 let last = performance.now();
2249 function frame(now) {
2250 const dt = Math.min(0.05, (now - last) / 1000);
2251 last = now;
2252 const dead = [];
2253 for (const [did, p] of pets) {
2254 p.tick(dt);
2255 const idle = now - p.lastMessage;
2256 if (idle >= IDLE_DEATH_MS) {
2257 dead.push(did);
2258 continue;
2259 }
2260 if (idle >= IDLE_FADE_2_MS) {
2261 p.el.style.opacity = "0.25";
2262 } else if (idle >= IDLE_FADE_1_MS) {
2263 p.el.style.opacity = "0.5";
2264 } else {
2265 p.el.style.opacity = "1";
2266 }
2267 }
2268 for (const did of dead) {
2269 pets.get(did).dispose();
2270 pets.delete(did);
2271 }
2272 requestAnimationFrame(frame);
2273 }
2274 requestAnimationFrame(frame);
2275
2276 window.addEventListener("resize", () => {
2277 for (const p of pets.values()) {
2278 p.x = Math.min(p.x, window.innerWidth - 110);
2279 }
2280 });