[READ-ONLY] Mirror of https://github.com/flo-bit/streamplace-overlay. flo-bit.dev/streamplace-overlay/
0

Configure Feed

Select the types of activity you want to include in your feed.

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 });