diff --git a/pets.css b/pets.css
new file mode 100644
index 0000000..43c945f
--- /dev/null
+++ b/pets.css
@@ -0,0 +1,914 @@
+/* ===== CSS Custom Properties (Design Tokens) ===== */
+ :root {
+ --font-system:
+ -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
+ --font-mono: ui-monospace, "SF Mono", Menlo, monospace;
+ --font-comic:
+ "Comic Sans MS", "Comic Sans", "Chalkboard SE", "Marker Felt",
+ system-ui, sans-serif;
+ --color-text-dim: rgba(255, 255, 255, 0.45);
+ --color-text-faint: rgba(255, 255, 255, 0.35);
+ --color-bg-overlay: rgba(20, 20, 28, 0.85);
+ --color-bg-pill: rgba(0, 0, 0, 0.32);
+ --color-shadow-text: 0 0 3px rgba(0, 0, 0, 0.8);
+ --color-shadow-pet: 0 6px 8px rgba(0, 0, 0, 0.35);
+ --pet-size: 110px;
+ --pet-bottom: 14px;
+ --anim-speed-default: 1;
+ --rate-limit-glow: drop-shadow(0 0 10px rgba(255, 40, 40, 0.95))
+ drop-shadow(0 0 22px rgba(255, 0, 0, 0.65));
+ }
+
+ html,
+ body {
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ height: 100%;
+ background: transparent;
+ overflow: hidden;
+ font-family: var(--font-system);
+ }
+
+ #stage {
+ position: fixed;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ height: 200px;
+ pointer-events: none;
+ }
+
+ #status {
+ position: fixed;
+ top: 6px;
+ right: 8px;
+ font-size: 10px;
+ color: var(--color-text-dim);
+ font-family: var(--font-mono);
+ pointer-events: none;
+ text-shadow: var(--color-shadow-text);
+ }
+ #status.hidden {
+ display: none;
+ }
+
+ #version {
+ position: fixed;
+ bottom: 4px;
+ right: 8px;
+ font-size: 10px;
+ color: var(--color-text-faint);
+ font-family: var(--font-mono);
+ pointer-events: none;
+ text-shadow: var(--color-shadow-text);
+ }
+
+ #reset-btn {
+ position: fixed;
+ bottom: 4px;
+ left: 8px;
+ width: 24px;
+ height: 24px;
+ background: transparent;
+ border: none;
+ color: var(--color-text-faint);
+ font-size: 14px;
+ cursor: pointer;
+ opacity: 0;
+ transition: opacity 0.3s;
+ z-index: 999;
+ pointer-events: auto;
+ }
+ #reset-btn:hover {
+ opacity: 0.8;
+ color: rgba(255, 255, 255, 0.8);
+ }
+ #stage:hover ~ #reset-btn,
+ #reset-btn:hover,
+ #reset-btn:focus-visible {
+ opacity: 0.6;
+ }
+
+ #reload-toast {
+ position: fixed;
+ top: 24px;
+ left: 50%;
+ transform: translateX(-50%) translateY(-12px);
+ padding: 8px 18px;
+ background: var(--color-bg-overlay);
+ color: #fff;
+ font-size: 13px;
+ font-weight: 600;
+ font-family: var(--font-mono);
+ letter-spacing: 0.06em;
+ border-radius: 999px;
+ border: 1px solid rgba(255, 255, 255, 0.18);
+ box-shadow: 0 4px 18px rgba(0, 0, 0, 0.5);
+ pointer-events: none;
+ opacity: 0;
+ z-index: 500;
+ animation: reload-toast 3.6s ease-out forwards;
+ }
+ @keyframes reload-toast {
+ 0% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-12px);
+ }
+ 8% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 80% {
+ opacity: 1;
+ transform: translateX(-50%) translateY(0);
+ }
+ 100% {
+ opacity: 0;
+ transform: translateX(-50%) translateY(-8px);
+ }
+ }
+
+ /* ===== Pet ===== */
+ .pet {
+ position: absolute;
+ bottom: var(--pet-bottom);
+ left: 0;
+ width: var(--pet-size);
+ height: var(--pet-size);
+ transform-origin: 50% 100%;
+ will-change: transform;
+ filter: var(--color-shadow-pet);
+ --anim-speed: var(--anim-speed-default);
+ }
+
+ .pet-flip {
+ width: 100%;
+ height: 100%;
+ transform-origin: 50% 100%;
+ position: relative;
+ transition: filter 0.25s ease;
+ }
+ .pet-flip svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ }
+
+ /* Rate-limit indicator — red glow around the pet */
+ .pet.rate-limited .pet-flip {
+ filter: var(--rate-limit-glow);
+ }
+
+ .rpg-sprite {
+ position: absolute;
+ bottom: 0;
+ left: 50%;
+ background-repeat: no-repeat;
+ image-rendering: pixelated;
+ image-rendering: crisp-edges;
+ }
+
+ /* ===== Label (handle pill + chat bubble) ===== */
+ .pet-label {
+ position: absolute;
+ left: 50%;
+ transform: translateX(-50%);
+ font-size: 11px;
+ font-weight: 700;
+ color: #fff;
+ text-shadow:
+ -1px -1px 0 #000,
+ 1px -1px 0 #000,
+ -1px 1px 0 #000,
+ 1px 1px 0 #000,
+ 0 0 6px rgba(0, 0, 0, 0.85);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 240px;
+ pointer-events: none;
+ letter-spacing: 0.02em;
+ background: var(--color-bg-pill);
+ padding: 1px 6px;
+ border-radius: 999px;
+ backdrop-filter: blur(2px);
+ transition:
+ background-color 0.18s,
+ color 0.18s;
+ }
+ .pet-label.chatting {
+ background: rgba(255, 255, 255, 0.96);
+ color: #1a1a1a;
+ text-shadow: none;
+ font-weight: 600;
+ font-family: var(--font-comic);
+ border-radius: 6px;
+ padding: 3px 8px;
+ border: 2px solid #1a1a1a;
+ box-shadow: 2px 2px 0 #1a1a1a;
+ white-space: normal;
+ text-align: center;
+ line-height: 1.25;
+ min-width: 80px;
+ max-width: min(80ch, 540px);
+ display: -webkit-box;
+ -webkit-line-clamp: 3;
+ -webkit-box-orient: vertical;
+ overflow: hidden;
+ word-break: break-word;
+ }
+
+ /* ===== Animations (all use calc() with --anim-speed) ===== */
+ .pet:not(.sprite) .body {
+ transform-origin: 50% 100%;
+ animation: bob calc(0.42s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet:not(.sprite).idle .body {
+ animation: breathe calc(2.6s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet:not(.sprite).sleep .body {
+ animation: sleep-breathe calc(3.4s / var(--anim-speed)) ease-in-out
+ infinite;
+ }
+
+ @keyframes bob {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-3px);
+ }
+ }
+ @keyframes breathe {
+ 0%,
+ 100% {
+ transform: scaleY(1) scaleX(1);
+ }
+ 50% {
+ transform: scaleY(0.97) scaleX(1.02);
+ }
+ }
+ @keyframes sleep-breathe {
+ 0%,
+ 100% {
+ transform: scaleY(0.96) scaleX(1.04) translateY(2px);
+ }
+ 50% {
+ transform: scaleY(0.92) scaleX(1.06) translateY(4px);
+ }
+ }
+
+ .pet.bunny .body {
+ animation: hop calc(0.55s / var(--anim-speed))
+ cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ }
+ .pet.bunny.idle .body {
+ animation: breathe calc(2.6s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet.bunny.sleep .body {
+ animation: sleep-breathe calc(3.4s / var(--anim-speed)) ease-in-out
+ infinite;
+ }
+ @keyframes hop {
+ 0%,
+ 100% {
+ transform: translateY(0) scaleY(1);
+ }
+ 25% {
+ transform: translateY(-22px) scaleY(1.04);
+ }
+ 50% {
+ transform: translateY(0) scaleY(0.94);
+ }
+ 75% {
+ transform: translateY(0) scaleY(1.02);
+ }
+ }
+
+ .pet.duck .body {
+ animation: waddle calc(0.55s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet.duck.idle .body {
+ animation: breathe calc(2.6s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet.duck.sleep .body {
+ animation: sleep-breathe calc(3.4s / var(--anim-speed)) ease-in-out
+ infinite;
+ }
+ @keyframes waddle {
+ 0%,
+ 100% {
+ transform: rotate(-5deg) translateY(0);
+ }
+ 50% {
+ transform: rotate(5deg) translateY(-1px);
+ }
+ }
+
+ .pet.goose .body {
+ animation: goose-strut calc(0.7s / var(--anim-speed)) ease-in-out
+ infinite;
+ }
+ .pet.goose.idle .body {
+ animation: breathe calc(2.6s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .pet.goose.sleep .body {
+ animation: sleep-breathe calc(3.4s / var(--anim-speed)) ease-in-out
+ infinite;
+ }
+ @keyframes goose-strut {
+ 0%,
+ 100% {
+ transform: rotate(-3deg) translateY(0);
+ }
+ 50% {
+ transform: rotate(3deg) translateY(-2px);
+ }
+ }
+
+ .tail-wag {
+ transform-origin: 0% 50%;
+ animation: wag calc(0.5s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .tail-swish {
+ transform-origin: 0% 100%;
+ animation: swish calc(1.6s / var(--anim-speed)) ease-in-out infinite;
+ }
+ @keyframes wag {
+ 0%,
+ 100% {
+ transform: rotate(-15deg);
+ }
+ 50% {
+ transform: rotate(15deg);
+ }
+ }
+ @keyframes swish {
+ 0%,
+ 100% {
+ transform: rotate(-8deg);
+ }
+ 50% {
+ transform: rotate(20deg);
+ }
+ }
+
+ .leg-front {
+ transform-origin: 50% 0%;
+ animation: leg-a calc(0.42s / var(--anim-speed)) ease-in-out infinite;
+ }
+ .leg-back {
+ transform-origin: 50% 0%;
+ animation: leg-b calc(0.42s / var(--anim-speed)) ease-in-out infinite;
+ }
+ @keyframes leg-a {
+ 0%,
+ 100% {
+ transform: rotate(20deg);
+ }
+ 50% {
+ transform: rotate(-20deg);
+ }
+ }
+ @keyframes leg-b {
+ 0%,
+ 100% {
+ transform: rotate(-20deg);
+ }
+ 50% {
+ transform: rotate(20deg);
+ }
+ }
+ .pet.idle .leg-front,
+ .pet.idle .leg-back,
+ .pet.sleep .leg-front,
+ .pet.sleep .leg-back {
+ animation: none;
+ transform: rotate(0);
+ }
+
+ .eye-lid {
+ transform-origin: 50% 50%;
+ transform: scaleY(0);
+ animation: blink calc(4s / var(--anim-speed)) ease-in-out infinite;
+ }
+ @keyframes blink {
+ 0%,
+ 92%,
+ 100% {
+ transform: scaleY(0);
+ }
+ 94%,
+ 96% {
+ transform: scaleY(1);
+ }
+ }
+ .pet.sleep .eye-lid {
+ transform: scaleY(1);
+ animation: none;
+ }
+
+ /* ===== Zzz, Heart, Hand ===== */
+ .zzz {
+ position: absolute;
+ top: -8px;
+ right: 8px;
+ width: 36px;
+ height: 36px;
+ opacity: 0;
+ pointer-events: none;
+ }
+ .pet.sleep .zzz {
+ animation: zzz-float 2.2s ease-in-out infinite;
+ }
+ @keyframes zzz-float {
+ 0% {
+ opacity: 0;
+ transform: translate(0, 0) scale(0.6);
+ }
+ 25% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(14px, -28px) scale(1.1);
+ }
+ }
+
+ .heart {
+ position: absolute;
+ top: 8px;
+ left: 50%;
+ width: 26px;
+ height: 26px;
+ transform: translateX(-50%) scale(0);
+ pointer-events: none;
+ }
+ .pet.happy .heart {
+ animation: heart-pop 1.2s ease-out forwards;
+ }
+ @keyframes heart-pop {
+ 0% {
+ transform: translate(-50%, 0) scale(0);
+ opacity: 0;
+ }
+ 20% {
+ transform: translate(-50%, -10px) scale(1.3);
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(-50%, -44px) scale(0.85);
+ opacity: 0;
+ }
+ }
+
+ .pet-rub {
+ position: absolute;
+ top: -22px;
+ left: 50%;
+ width: 36px;
+ height: 36px;
+ margin-left: -18px;
+ pointer-events: none;
+ opacity: 0;
+ will-change: transform;
+ filter: drop-shadow(0 2px 3px rgba(0, 0, 0, 0.45));
+ }
+ .pet.petted .pet-rub {
+ animation: pet-rub 1.8s ease-in-out forwards;
+ }
+ @keyframes pet-rub {
+ 0% {
+ opacity: 0;
+ transform: translateY(8px) rotate(-12deg);
+ }
+ 10% {
+ opacity: 1;
+ transform: translateY(2px) rotate(-12deg);
+ }
+ 25% {
+ transform: translateY(-4px) rotate(8deg);
+ }
+ 40% {
+ transform: translateY(2px) rotate(-12deg);
+ }
+ 55% {
+ transform: translateY(-4px) rotate(8deg);
+ }
+ 70% {
+ transform: translateY(2px) rotate(-12deg);
+ }
+ 85% {
+ transform: translateY(-4px) rotate(8deg);
+ opacity: 1;
+ }
+ 100% {
+ transform: translateY(-12px) rotate(0deg);
+ opacity: 0;
+ }
+ }
+
+ .pet.spawning {
+ animation: spawn-in 0.6s ease-out;
+ }
+ @keyframes spawn-in {
+ 0% {
+ opacity: 0;
+ }
+ 100% {
+ opacity: 1;
+ }
+ }
+
+ /* ===== Spawn star burst ===== */
+ .fx-spawn-stars {
+ position: fixed;
+ pointer-events: none;
+ z-index: 300;
+ overflow: visible;
+ }
+ .star-particle {
+ position: absolute;
+ animation: star-burst 0.9s ease-out forwards;
+ pointer-events: none;
+ line-height: 1;
+ user-select: none;
+ }
+ @keyframes star-burst {
+ 0% {
+ opacity: 1;
+ transform: translate(0, 0) scale(1);
+ }
+ 100% {
+ opacity: 0;
+ transform: translate(var(--star-dx, 0), var(--star-dy, -80px))
+ scale(0.2);
+ }
+ }
+
+ /* ===== Bang command effects (scene-wide overlays) ===== */
+ @keyframes fx-fade-out {
+ 0% {
+ opacity: 1;
+ }
+ 100% {
+ opacity: 0;
+ }
+ }
+
+ .fx-party {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ z-index: 200;
+ mix-blend-mode: screen;
+ animation: fx-fade-out 0.6s ease-out 7s forwards;
+ }
+ .party-light {
+ position: absolute;
+ width: 60vw;
+ height: 60vw;
+ border-radius: 50%;
+ filter: blur(48px);
+ opacity: 0.85;
+ will-change: transform;
+ }
+ .party-1 {
+ background: radial-gradient(circle, #ff3a8e 0%, transparent 60%);
+ animation: party-orbit 3.6s linear infinite;
+ }
+ .party-2 {
+ background: radial-gradient(circle, #3affc8 0%, transparent 60%);
+ animation: party-orbit 3.6s linear infinite -0.9s;
+ }
+ .party-3 {
+ background: radial-gradient(circle, #ffd93d 0%, transparent 60%);
+ animation: party-orbit 3.6s linear infinite -1.8s;
+ }
+ .party-4 {
+ background: radial-gradient(circle, #6a8eff 0%, transparent 60%);
+ animation: party-orbit 3.6s linear infinite -2.7s;
+ }
+ @keyframes party-orbit {
+ 0% {
+ transform: translate(-30vw, -20vh);
+ }
+ 25% {
+ transform: translate(70vw, -30vh);
+ }
+ 50% {
+ transform: translate(80vw, 60vh);
+ }
+ 75% {
+ transform: translate(-30vw, 50vh);
+ }
+ 100% {
+ transform: translate(-30vw, -20vh);
+ }
+ }
+
+ .fx-snow,
+ .fx-confetti,
+ .fx-hearts,
+ .fx-rain {
+ position: fixed;
+ inset: 0;
+ pointer-events: none;
+ overflow: hidden;
+ z-index: 150;
+ animation: fx-fade-out 1s ease-out forwards;
+ }
+ .fx-snow {
+ animation-delay: 13s;
+ }
+ .fx-confetti {
+ animation-delay: 7s;
+ }
+ .fx-hearts {
+ animation-delay: 6s;
+ }
+ .fx-rain {
+ animation-delay: 11s;
+ }
+
+ .raindrop {
+ position: absolute;
+ top: -30px;
+ width: 2px;
+ background: linear-gradient(transparent, #6cb8ff 60%, #b3dbff);
+ border-radius: 1px;
+ animation-name: rain-fall;
+ animation-timing-function: linear;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ }
+ @keyframes rain-fall {
+ 0% {
+ transform: translateY(0);
+ }
+ 100% {
+ transform: translateY(110vh);
+ }
+ }
+
+ .snowflake {
+ position: absolute;
+ top: -30px;
+ color: white;
+ text-shadow:
+ 0 0 6px rgba(150, 200, 255, 0.7),
+ 0 0 12px rgba(255, 255, 255, 0.4);
+ animation-name: snow-fall;
+ animation-timing-function: linear;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ user-select: none;
+ }
+ @keyframes snow-fall {
+ 0% {
+ transform: translateY(0) translateX(0);
+ }
+ 100% {
+ transform: translateY(110vh) translateX(var(--sway, 0));
+ }
+ }
+
+ .confetti-piece {
+ position: absolute;
+ top: -30px;
+ border-radius: 2px;
+ animation-name: confetti-fall;
+ animation-timing-function: linear;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ }
+ @keyframes confetti-fall {
+ 0% {
+ transform: translate(0, 0) rotate(0deg);
+ }
+ 100% {
+ transform: translate(var(--sway, 0), 110vh)
+ rotate(var(--spin, 720deg));
+ }
+ }
+
+ .heart-piece {
+ position: absolute;
+ bottom: -40px;
+ font-size: 24px;
+ animation-name: heart-rise;
+ animation-timing-function: ease-out;
+ animation-iteration-count: 1;
+ animation-fill-mode: forwards;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.4));
+ user-select: none;
+ }
+ @keyframes heart-rise {
+ 0% {
+ transform: translate(0, 0) scale(0.4);
+ opacity: 0;
+ }
+ 15% {
+ opacity: 1;
+ }
+ 100% {
+ transform: translate(var(--sway, 0), -110vh)
+ scale(var(--end-scale, 1.1));
+ opacity: 0;
+ }
+ }
+
+ .fx-barrelroll {
+ transform-origin: 50% 50%;
+ animation: barrelroll 1.4s cubic-bezier(0.5, 0, 0.5, 1) forwards;
+ }
+ @keyframes barrelroll {
+ 0% {
+ transform: rotate(0deg);
+ }
+ 100% {
+ transform: rotate(360deg);
+ }
+ }
+
+ .fx-quake {
+ animation: quake 0.55s linear;
+ }
+ @keyframes quake {
+ 0%,
+ 100% {
+ transform: translate(0, 0);
+ }
+ 8% {
+ transform: translate(-12px, 5px);
+ }
+ 18% {
+ transform: translate(10px, -4px);
+ }
+ 28% {
+ transform: translate(-8px, 6px);
+ }
+ 38% {
+ transform: translate(7px, -3px);
+ }
+ 48% {
+ transform: translate(-6px, 5px);
+ }
+ 58% {
+ transform: translate(5px, -4px);
+ }
+ 68% {
+ transform: translate(-4px, 3px);
+ }
+ 78% {
+ transform: translate(3px, -2px);
+ }
+ 88% {
+ transform: translate(-1px, 1px);
+ }
+ }
+
+ /* Jump */
+ .pet.jumping .pet-flip {
+ animation: jump 0.75s cubic-bezier(0.3, 0, 0.3, 1);
+ }
+ @keyframes jump {
+ 0% {
+ translate: 0 0;
+ }
+ 45% {
+ translate: 0 -58px;
+ }
+ 100% {
+ translate: 0 0;
+ }
+ }
+
+ /* Dance */
+ .pet.dancing .pet-flip {
+ animation: dance 3s cubic-bezier(0.45, 0, 0.45, 1);
+ }
+ @keyframes dance {
+ 0% {
+ translate: 0 0;
+ rotate: 0deg;
+ scale: 1;
+ }
+ 5% {
+ translate: -22px -2px;
+ rotate: -32deg;
+ scale: 1;
+ }
+ 10% {
+ translate: 18px -16px;
+ rotate: 28deg;
+ scale: 1.06;
+ }
+ 15% {
+ translate: -22px -2px;
+ rotate: -32deg;
+ scale: 1;
+ }
+ 20% {
+ translate: 18px -16px;
+ rotate: 28deg;
+ scale: 1.06;
+ }
+ 25% {
+ translate: 0 0;
+ rotate: 0deg;
+ scale: 1;
+ }
+ 35% {
+ translate: 0 -55px;
+ rotate: 360deg;
+ scale: 1.12;
+ }
+ 45% {
+ translate: 0 -25px;
+ rotate: 720deg;
+ scale: 1;
+ }
+ 52% {
+ translate: -22px 0;
+ rotate: -32deg;
+ scale: 1;
+ }
+ 58% {
+ translate: 20px -14px;
+ rotate: 30deg;
+ scale: 1.06;
+ }
+ 64% {
+ translate: -22px 0;
+ rotate: -32deg;
+ scale: 1;
+ }
+ 70% {
+ translate: 18px -14px;
+ rotate: 28deg;
+ scale: 1.06;
+ }
+ 76% {
+ translate: 0 -2px;
+ rotate: 0deg;
+ scale: 1;
+ }
+ 86% {
+ translate: 0 -40px;
+ rotate: 380deg;
+ scale: 1.18;
+ }
+ 96% {
+ translate: 0 -2px;
+ rotate: 0deg;
+ scale: 0.94;
+ }
+ 100% {
+ translate: 0 0;
+ rotate: 0deg;
+ scale: 1;
+ }
+ }
+
+ /* Fart cloud */
+ .fart {
+ position: absolute;
+ bottom: 4px;
+ width: 90px;
+ height: 70px;
+ opacity: 0;
+ pointer-events: none;
+ }
+ .fart svg {
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ }
+ .fart-puff {
+ transform-box: fill-box;
+ transform-origin: center;
+ animation-name: fart-puff;
+ animation-timing-function: ease-out;
+ animation-fill-mode: forwards;
+ opacity: 0;
+ }
+ .pet.farting .fart {
+ opacity: 1;
+ }
+ @keyframes fart-puff {
+ 0% {
+ transform: translate(0, 0) scale(var(--fart-start, 0.2));
+ opacity: 0;
+ }
+ 18% {
+ opacity: var(--fart-peak, 0.85);
+ }
+ 100% {
+ transform: translate(var(--fart-dx, 0), var(--fart-dy, -25px))
+ scale(var(--fart-end, 1.4));
+ opacity: 0;
+ }
+ }
diff --git a/pets.html b/pets.html
index 5324c0e..d9da2a1 100644
--- a/pets.html
+++ b/pets.html
@@ -1,2161 +1,23 @@
-
+
-
-
-Stream Pets
-
-
-
-
-
-
-
-
-
-
+
+
+ Stream Pets
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pets.js b/pets.js
new file mode 100644
index 0000000..41c61e1
--- /dev/null
+++ b/pets.js
@@ -0,0 +1,2103 @@
+/* ---------- SVG factories (fallback pets) ---------- */
+
+ const SVGS = {
+ cat: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ dog: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ bunny: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ duck: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ hamster: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ goose: (color, accent) => `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `,
+ };
+
+ const ZZZ_SVG = `
+
+ `;
+ const HEART_SVG = `
+
+ `;
+ // Cartoon hand for !pet — palm-down so it reads as patting a head from above.
+ const HAND_SVG = `
+
+ `;
+ const FART_GREENS = [
+ "#9bbf2f",
+ "#aacc3a",
+ "#b8d650",
+ "#8eb02a",
+ "#7ea020",
+ "#c4dc66",
+ "#a3c544",
+ "#88a52a",
+ "#d4e878",
+ ];
+
+ function randomFartSvg() {
+ const n = 4 + Math.floor(Math.random() * 5); // 4–8 puffs
+ const rand = (a, b) => a + Math.random() * (b - a);
+ let body = "";
+ let maxDur = 0;
+ for (let i = 0; i < n; i++) {
+ const cx = rand(12, 78);
+ const cy = rand(34, 58);
+ const r = rand(5, 17);
+ const fill =
+ FART_GREENS[Math.floor(Math.random() * FART_GREENS.length)];
+ const peak = rand(0.5, 0.9);
+ const dx = rand(-22, 22);
+ const dy = rand(-12, -42);
+ const startScale = rand(0.12, 0.35);
+ const endScale = rand(1.0, 2.0);
+ const duration = rand(0.9, 1.9);
+ const delay = rand(0, 0.5);
+ if (duration + delay > maxDur) maxDur = duration + delay;
+ body += ``;
+ }
+ return {
+ svg: ``,
+ durationMs: maxDur * 1000 + 100,
+ };
+ }
+
+ /* ---------- Pet variants (SVG fallback) ---------- */
+
+ const PET_VARIANTS = {
+ cat: [
+ { c: "#f4a23c", a: "#cc7a18" },
+ { c: "#3a3a3a", a: "#1a1a1a" },
+ { c: "#fafafa", a: "#a8a8a8" },
+ { c: "#a87752", a: "#5e3d24" },
+ { c: "#cccccc", a: "#7a7a7a" },
+ ],
+ dog: [
+ { c: "#e8c39e", a: "#a87645" },
+ { c: "#5a3d2a", a: "#332010" },
+ { c: "#fafafa", a: "#a8a8a8" },
+ { c: "#d8a878", a: "#8e5e30" },
+ ],
+ bunny: [
+ { c: "#fafafa", a: "#bdbdbd" },
+ { c: "#d8b899", a: "#9c7a5a" },
+ { c: "#3a3a3a", a: "#1a1a1a" },
+ { c: "#c8b4d4", a: "#8c6e9e" },
+ ],
+ duck: [
+ { c: "#ffd93d", a: "#cf9a00" },
+ { c: "#fafafa", a: "#bdbdbd" },
+ ],
+ hamster: [
+ { c: "#caa472", a: "#8e6a3f" },
+ { c: "#fafafa", a: "#a8a8a8" },
+ { c: "#e8b8a0", a: "#b07858" },
+ ],
+ goose: [{ c: "#ffffff", a: "#bcbcbc" }],
+ };
+
+ const PET_BASE = {
+ cat: { speed: 35, scale: 1.0 },
+ dog: { speed: 50, scale: 1.05 },
+ bunny: { speed: 0, scale: 0.95 },
+ duck: { speed: 28, scale: 0.9 },
+ hamster: { speed: 60, scale: 0.75 },
+ goose: { speed: 32, scale: 1.1 },
+ };
+
+ const RANDOM_TYPES = ["cat", "dog", "bunny", "duck", "hamster"];
+
+ function hashString(s) {
+ let h = 5381;
+ for (let i = 0; i < s.length; i++)
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
+ return Math.abs(h);
+ }
+
+ function clamp(v, [lo, hi]) {
+ return Math.max(lo, Math.min(hi, v));
+ }
+
+ /* Bad-word redaction for chat bubbles. Strong profanity only; mild words pass through. */
+ const BAD_WORDS_RE =
+ /\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;
+ function redactBadWords(s) {
+ return s.replace(BAD_WORDS_RE, (m) => "*".repeat(m.length));
+ }
+
+ // User-chosen pet type, takes precedence over both sprite and handle-based picks.
+ // did → 'cat' | 'dog' | 'bunny' | 'duck' | 'hamster' | 'goose'
+ const petTypeOverrides = new Map();
+
+ function pickPet(did, handle, sprite) {
+ const override = petTypeOverrides.get(did);
+ if (override && PET_VARIANTS[override]) {
+ const h = hashString(did);
+ const v =
+ PET_VARIANTS[override][(h >> 5) % PET_VARIANTS[override].length];
+ return {
+ mode: "svg",
+ type: override,
+ color: v.c,
+ accent: v.a,
+ ...PET_BASE[override],
+ };
+ }
+ if (sprite) {
+ return { mode: "sprite", sprite, scale: 1.0, speed: 42 };
+ }
+ const lower = (handle || "").toLowerCase();
+ if (lower.includes("goose")) {
+ const v = PET_VARIANTS.goose[0];
+ return {
+ mode: "svg",
+ type: "goose",
+ color: v.c,
+ accent: v.a,
+ ...PET_BASE.goose,
+ };
+ }
+ // Per chat (felinus.fish): handles containing "fish" become cats.
+ if (lower.includes("fish")) {
+ const h = hashString(did);
+ const v = PET_VARIANTS.cat[(h >> 5) % PET_VARIANTS.cat.length];
+ return {
+ mode: "svg",
+ type: "cat",
+ color: v.c,
+ accent: v.a,
+ ...PET_BASE.cat,
+ };
+ }
+ const h = hashString(did);
+ const type = RANDOM_TYPES[h % RANDOM_TYPES.length];
+ const v = PET_VARIANTS[type][(h >> 5) % PET_VARIANTS[type].length];
+ return {
+ mode: "svg",
+ type,
+ color: v.c,
+ accent: v.a,
+ ...PET_BASE[type],
+ };
+ }
+
+ /* ---------- Pet ---------- */
+
+ const SPRITE_DISPLAY_H = 96; // px tall when rendered
+ const WALK_CYCLE = [1, 0, 1, 2];
+ const WALK_FPS = 6;
+
+ /* Lazy-build a radial displacement map for the bulge filter. R/G channels
+ encode (dx, dy) sample offsets — center → no shift, mid-radius → strongly
+ outward push (so center magnifies / edges compress = fisheye / bulge). */
+ let _bulgeMapDataUrl = null;
+ function getBulgeDisplacementMap() {
+ if (_bulgeMapDataUrl) return _bulgeMapDataUrl;
+ const size = 128;
+ const c = document.createElement("canvas");
+ c.width = c.height = size;
+ const ctx = c.getContext("2d");
+ const imgData = ctx.createImageData(size, size);
+ const cx = (size - 1) / 2,
+ cy = (size - 1) / 2;
+ const maxR = size / 2;
+ for (let y = 0; y < size; y++) {
+ for (let x = 0; x < size; x++) {
+ const dx = x - cx,
+ dy = y - cy;
+ const d = Math.sqrt(dx * dx + dy * dy);
+ const t = Math.min(1, d / maxR);
+ // Outward-pointing unit vector
+ const ux = d > 0.0001 ? dx / d : 0;
+ const uy = d > 0.0001 ? dy / d : 0;
+ // Magnitude: 0 at center, peaks at ~0.6 of radius, 0 at edge — gives
+ // the classic barrel-bulge profile (centre stretches, edges fall back).
+ // Vectors point INWARD (toward center): a slightly-off-center output
+ // pixel samples from a point even closer to centre, so the centre
+ // appears magnified — a true bulge / convex lens. (An outward-pointing
+ // map gives the opposite, pinch-style distortion.)
+ const mag = Math.sin(t * Math.PI);
+ const i = (y * size + x) * 4;
+ imgData.data[i] = Math.round(128 - ux * mag * 127);
+ imgData.data[i + 1] = Math.round(128 - uy * mag * 127);
+ imgData.data[i + 2] = 128;
+ imgData.data[i + 3] = 255;
+ }
+ }
+ ctx.putImageData(imgData, 0, 0);
+ _bulgeMapDataUrl = c.toDataURL("image/png");
+ return _bulgeMapDataUrl;
+ }
+
+ class Pet {
+ constructor(spec, stage, handle) {
+ this.mode = spec.mode;
+ this.scale = spec.scale;
+ this.speed = spec.speed;
+ this.lastMessage = performance.now();
+ this.isHopper = spec.mode === "svg" && spec.type === "bunny";
+
+ this.el = document.createElement("div");
+ this.el.className = `pet spawning ${spec.mode === "svg" ? spec.type : "sprite"}`;
+
+ this.flipEl = document.createElement("div");
+ this.flipEl.className = "pet-flip";
+
+ if (spec.mode === "svg") {
+ this.flipEl.innerHTML = `
+
+ `;
+ this.visualHeight = 110 * this.scale;
+ } else {
+ const sp = spec.sprite;
+ const ratio = SPRITE_DISPLAY_H / sp.frameHeight;
+ this.spriteFrameW = sp.frameWidth * ratio;
+ this.spriteFrameH = sp.frameHeight * ratio;
+ this.spriteEl = document.createElement("div");
+ this.spriteEl.className = "rpg-sprite";
+ this.spriteEl.style.backgroundImage = `url("${sp.imageUrl}")`;
+ this.spriteEl.style.backgroundSize = `${sp.width * ratio}px ${sp.height * ratio}px`;
+ this.spriteEl.style.width = `${this.spriteFrameW}px`;
+ this.spriteEl.style.height = `${this.spriteFrameH}px`;
+ this.spriteEl.style.marginLeft = `${-this.spriteFrameW / 2}px`;
+ this.sprite = sp;
+ this.flipEl.appendChild(this.spriteEl);
+ this.visualHeight = this.spriteFrameH;
+ }
+
+ this.zzzEl = document.createElement("div");
+ this.zzzEl.className = "zzz";
+ this.zzzEl.innerHTML = ZZZ_SVG;
+
+ this.heartEl = document.createElement("div");
+ this.heartEl.className = "heart";
+ this.heartEl.innerHTML = HEART_SVG;
+
+ this.fartEl = document.createElement("div");
+ this.fartEl.className = "fart";
+
+ this.rubEl = document.createElement("div");
+ this.rubEl.className = "pet-rub";
+ this.rubEl.innerHTML = HAND_SVG;
+
+ this.labelEl = document.createElement("div");
+ this.labelEl.className = "pet-label";
+
+ this.el.appendChild(this.flipEl);
+ this.el.appendChild(this.zzzEl);
+ this.el.appendChild(this.heartEl);
+ this.el.appendChild(this.fartEl);
+ this.el.appendChild(this.rubEl);
+ this.el.appendChild(this.labelEl);
+
+ this.setHandle(handle);
+
+ const fromLeft = Math.random() < 0.5;
+ this.x = fromLeft ? -130 : window.innerWidth + 20;
+ this.dir = fromLeft ? 1 : -1;
+
+ this.state = "walk";
+ this.stateTimer = 0;
+ this.nextDecision = 4 + Math.random() * 5;
+ this.happyUntil = 0;
+ this.speedMul = 1;
+ this.depthScale = 1;
+ this.stretchX = 1;
+ this.stretchY = 1;
+ this.bulgeLevel = 0;
+ this.blurLevel = 0;
+ this.filterId = Math.random().toString(36).slice(2, 10);
+
+ stage.appendChild(this.el);
+ this.applyTransform();
+
+ if (spec.mode === "svg") {
+ this.flipEl
+ .querySelectorAll(
+ ".body, .leg-front, .leg-back, .tail-wag, .tail-swish, .eye-lid",
+ )
+ .forEach(
+ (n) => (n.style.animationDelay = `${Math.random() * -2}s`),
+ );
+ }
+
+ setTimeout(() => this.el.classList.remove("spawning"), 700);
+ }
+
+ setHandle(handle) {
+ this.handle = handle;
+ if (!this.showingMessage) {
+ this.labelEl.textContent = handle ? "@" + handle : "";
+ }
+ }
+
+ applyTransform() {
+ this.el.style.transform = `translate3d(${this.x}px, 0, 0) scale(${this.depthScale})`;
+ if (this.mode === "svg") {
+ const flip = this.dir < 0 ? -1 : 1;
+ this.flipEl.style.transform = `scale(${this.scale * flip * this.stretchX}, ${this.scale * this.stretchY})`;
+ } else {
+ this.flipEl.style.transform = `scale(${this.scale * this.stretchX}, ${this.scale * this.stretchY})`;
+ }
+ this.labelEl.style.top = "auto";
+ this.labelEl.style.bottom = `${this.visualHeight + 8}px`;
+ // Position fart cloud behind the pet (opposite of facing direction)
+ this.fartEl.style.left = this.dir > 0 ? "-30px" : "auto";
+ this.fartEl.style.right = this.dir > 0 ? "auto" : "-30px";
+ // Bigger pets render in front
+ this.el.style.zIndex = Math.round(this.depthScale * 100);
+ }
+
+ updateSpriteFrame() {
+ if (!this.spriteEl) return;
+ const sp = this.sprite;
+ let row, col;
+ if (this.state === "sleep") {
+ row = 0;
+ col = 1;
+ } else if (this.state === "walk") {
+ row = this.dir < 0 ? 1 : 2;
+ const fps = WALK_FPS * this.speedMul;
+ const idx =
+ Math.floor(performance.now() / (1000 / fps)) % WALK_CYCLE.length;
+ col = WALK_CYCLE[idx];
+ } else {
+ row = this.dir < 0 ? 1 : 2;
+ col = 1;
+ }
+ row = Math.min(row, sp.rows - 1);
+ col = Math.min(col, sp.columns - 1);
+ this.spriteEl.style.backgroundPosition = `${-col * this.spriteFrameW}px ${-row * this.spriteFrameH}px`;
+ }
+
+ setState(s) {
+ if (this.state === s) return;
+ this.el.classList.remove("idle", "sleep", "happy");
+ this.state = s;
+ if (s === "idle") this.el.classList.add("idle");
+ if (s === "sleep") this.el.classList.add("sleep");
+ if (s === "happy") {
+ this.el.classList.add("happy");
+ this.happyUntil = performance.now() + 1200;
+ }
+ }
+
+ setDirection(dir) {
+ this.dir = dir;
+ if (this.state !== "walk") this.setState("walk");
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ setSpeedMul(mul) {
+ this.speedMul = clamp(mul, LIMITS.speedMul);
+ this.el.style.setProperty("--anim-speed", this.speedMul);
+ if (this.state !== "walk") this.setState("walk");
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ setDepthScale(scale) {
+ this.depthScale = clamp(scale, LIMITS.depthScale);
+ this._rebalance("depthScale");
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ setStretchX(mul) {
+ this.stretchX = clamp(mul, LIMITS.stretchX);
+ this._rebalance("stretchX");
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ setStretchY(mul) {
+ this.stretchY = clamp(mul, LIMITS.stretchY);
+ this._rebalance("stretchY");
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ // !reset — restore all per-pet transforms to defaults.
+ resetAll() {
+ this.speedMul = 1;
+ this.depthScale = 1;
+ this.stretchX = 1;
+ this.stretchY = 1;
+ this.el.style.setProperty("--anim-speed", 1);
+ this.setBulgeLevel(0);
+ this.setBlurLevel(0);
+ this.lastMessage = performance.now();
+ this.applyTransform();
+ saveStateDebounced();
+ }
+
+ // Bulge / fisheye: real radial-pinch distortion via feDisplacementMap fed by
+ // a canvas-generated vector field. Stacked with `bulgex3` etc.
+ setBulgeLevel(level) {
+ this.bulgeLevel = clamp(level, LIMITS.bulgeLevel);
+ const filterId = `bulge-${this.filterId}`;
+ const defs = document.querySelector("#filter-defs defs");
+ let filter = document.getElementById(filterId);
+ if (this.bulgeLevel <= 0) {
+ if (filter) filter.remove();
+ } else {
+ const NS = "http://www.w3.org/2000/svg";
+ if (!filter) {
+ filter = document.createElementNS(NS, "filter");
+ filter.id = filterId;
+ filter.setAttribute("x", "-30%");
+ filter.setAttribute("y", "-30%");
+ filter.setAttribute("width", "160%");
+ filter.setAttribute("height", "160%");
+ filter.setAttribute("color-interpolation-filters", "sRGB");
+ const feImage = document.createElementNS(NS, "feImage");
+ feImage.setAttributeNS(
+ "http://www.w3.org/1999/xlink",
+ "xlink:href",
+ getBulgeDisplacementMap(),
+ );
+ feImage.setAttribute("href", getBulgeDisplacementMap());
+ feImage.setAttribute("result", "map");
+ feImage.setAttribute("preserveAspectRatio", "none");
+ feImage.setAttribute("width", "100%");
+ feImage.setAttribute("height", "100%");
+ filter.appendChild(feImage);
+ const disp = document.createElementNS(NS, "feDisplacementMap");
+ disp.setAttribute("in", "SourceGraphic");
+ disp.setAttribute("in2", "map");
+ disp.setAttribute("xChannelSelector", "R");
+ disp.setAttribute("yChannelSelector", "G");
+ filter.appendChild(disp);
+ defs.appendChild(filter);
+ }
+ // Each level = a noticeable but bounded distortion.
+ const disp = filter.querySelector("feDisplacementMap");
+ disp.setAttribute("scale", String(this.bulgeLevel * 14));
+ }
+ this._applyVisualFilters();
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ // Per-pet blur — `blur`/`blurx5` add ≈2px steps; `unblur` clears.
+ setBlurLevel(level) {
+ this.blurLevel = clamp(level, LIMITS.blurLevel);
+ this._applyVisualFilters();
+ this.lastMessage = performance.now();
+ saveStateDebounced();
+ }
+
+ // Combine bulge (SVG filter) and blur (CSS function) into the single
+ // filter chain on flipEl. Order matters: bulge first warps the source,
+ // then blur softens — feels more like an out-of-focus bulge than a
+ // bulge-of-blur.
+ _applyVisualFilters() {
+ const parts = [];
+ if (this.bulgeLevel > 0) parts.push(`url(#bulge-${this.filterId})`);
+ if (this.blurLevel > 0) parts.push(`blur(${this.blurLevel * 2}px)`);
+ this.flipEl.style.filter = parts.join(" ");
+ }
+
+ // Conservation mode: total visible area (depthScale^2 * stretchX * stretchY) stays ≈1.
+ // When the user changes one dimension, the other two scale to compensate.
+ _rebalance(changedKey) {
+ if (!CONSERVATION) return;
+ const product = this.depthScale * this.stretchX * this.stretchY;
+ if (product <= 0) return;
+ const f = Math.sqrt(product); // = ratio over target=1 → divide-by to compensate via two axes
+ if (Math.abs(f - 1) < 0.005) return;
+ if (changedKey === "depthScale") {
+ this.stretchX = clamp(this.stretchX / f, LIMITS.stretchX);
+ this.stretchY = clamp(this.stretchY / f, LIMITS.stretchY);
+ } else if (changedKey === "stretchX") {
+ this.depthScale = clamp(this.depthScale / f, LIMITS.depthScale);
+ this.stretchY = clamp(this.stretchY / f, LIMITS.stretchY);
+ } else if (changedKey === "stretchY") {
+ this.depthScale = clamp(this.depthScale / f, LIMITS.depthScale);
+ this.stretchX = clamp(this.stretchX / f, LIMITS.stretchX);
+ }
+ }
+
+ fart() {
+ this.el.classList.remove("farting");
+ void this.el.offsetWidth;
+ const { svg, durationMs } = randomFartSvg();
+ this.fartEl.innerHTML = svg;
+ this.el.classList.add("farting");
+ setTimeout(() => {
+ this.el.classList.remove("farting");
+ this.fartEl.innerHTML = "";
+ }, durationMs);
+ this.lastMessage = performance.now();
+ }
+
+ jump() {
+ this.el.classList.remove("jumping");
+ void this.el.offsetWidth;
+ this.el.classList.add("jumping");
+ setTimeout(() => this.el.classList.remove("jumping"), 800);
+ this.lastMessage = performance.now();
+ }
+
+ dance() {
+ this.el.classList.remove("dancing");
+ void this.el.offsetWidth;
+ this.el.classList.add("dancing");
+ setTimeout(() => this.el.classList.remove("dancing"), 3050);
+ this.lastMessage = performance.now();
+ }
+
+ showMessage(rawText) {
+ if (!rawText) return;
+ const safe = redactBadWords(String(rawText)).slice(0, 100).trim();
+ if (!safe) return;
+ this.showingMessage = true;
+ this.labelEl.textContent = safe;
+ this.labelEl.classList.add("chatting");
+ clearTimeout(this._messageHide);
+ this._messageHide = setTimeout(() => {
+ this.showingMessage = false;
+ this.labelEl.classList.remove("chatting");
+ this.labelEl.textContent = this.handle ? "@" + this.handle : "";
+ }, 4500);
+ }
+
+ // !pet @target — show a cartoon hand patting the pet for ~1.8s, plus a heart.
+ petPat() {
+ this.el.classList.remove("petted");
+ void this.el.offsetWidth;
+ this.el.classList.add("petted");
+ this.pulseHeart();
+ setTimeout(() => this.el.classList.remove("petted"), 1900);
+ this.lastMessage = performance.now();
+ }
+
+ pulseHeart() {
+ this.el.classList.remove("happy", "idle", "sleep");
+ void this.el.offsetWidth;
+ this.state = "happy";
+ this.el.classList.add("happy");
+ this.happyUntil = performance.now() + 1200;
+ this.lastMessage = performance.now();
+ }
+
+ tick(dt) {
+ this.stateTimer += dt;
+ const now = performance.now();
+
+ if (this.state === "happy" && now > this.happyUntil) {
+ this.setState("walk");
+ this.stateTimer = 0;
+ }
+
+ if (this.state !== "happy" && this.stateTimer > this.nextDecision) {
+ const r = Math.random();
+ if (this.state === "sleep") {
+ this.setState("walk");
+ } else if (r < 0.6) {
+ this.setState("walk");
+ if (Math.random() < 0.2) this.dir *= -1;
+ } else if (r < 0.88) {
+ this.setState("idle");
+ } else {
+ this.setState("sleep");
+ }
+ this.stateTimer = 0;
+ this.nextDecision = 3 + Math.random() * 5;
+ }
+
+ if (this.state === "walk") {
+ const baseSpeed = this.isHopper ? 70 : this.speed;
+ this.x += this.dir * baseSpeed * this.speedMul * dt;
+ const max = window.innerWidth - 110;
+ if (this.x < 0) {
+ this.x = 0;
+ this.dir = 1;
+ }
+ if (this.x > max) {
+ this.x = max;
+ this.dir = -1;
+ }
+ }
+
+ if (this.mode === "sprite") this.updateSpriteFrame();
+ this.applyTransform();
+ }
+
+ dispose() {
+ const f = document.getElementById(`bulge-${this.filterId}`);
+ if (f) f.remove();
+ this.el.remove();
+ }
+ }
+
+ /* ---------- Profile resolver: handle + PDS + RPG sprite ---------- */
+
+ const profileCache = new Map(); // did -> { handle, pds, sprite }
+ const profileFetchedAt = new Map(); // did -> Date.now() when last fetched
+ const profilePending = new Map(); // did -> Promise
+ const PROFILE_TTL_MS = 60 * 1000;
+
+ function loadImage(url) {
+ return new Promise((resolve, reject) => {
+ const img = new Image();
+ img.onload = () => resolve(img);
+ img.onerror = () => reject(new Error("image load failed: " + url));
+ img.src = url;
+ });
+ }
+
+ async function fetchSprite(pds, did) {
+ const url = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(did)}&collection=actor.rpg.sprite&rkey=self`;
+ const r = await fetch(url);
+ if (!r.ok) return null;
+ const data = await r.json();
+ const rec = data?.value;
+ const cid = rec?.spriteSheet?.ref?.$link;
+ const fw = Number(rec?.frameWidth);
+ const fh = Number(rec?.frameHeight);
+ if (
+ !rec ||
+ !cid ||
+ !isFinite(fw) ||
+ !isFinite(fh) ||
+ fw <= 0 ||
+ fh <= 0
+ )
+ return null;
+ const imageUrl = `${pds.replace(/\/$/, "")}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(did)}&cid=${cid}`;
+ let img;
+ try {
+ img = await loadImage(imageUrl);
+ } catch {
+ return null;
+ }
+ // The image must hold at least one frame; otherwise the sprite is unusable.
+ if (img.naturalWidth < fw || img.naturalHeight < fh) {
+ console.warn(
+ "[pets] sprite frame larger than image; falling back to SVG",
+ did,
+ );
+ return null;
+ }
+ // Clamp declared rows/columns to what physically fits in the image. If
+ // someone uploads rows=4 but only 3 rows actually fit, we render 3 rows
+ // instead of refusing the whole sprite — the previous strict check was
+ // rejecting otherwise-fine sprites whose dims were a few pixels off.
+ const maxRows = Math.max(1, Math.floor(img.naturalHeight / fh));
+ const maxCols = Math.max(1, Math.floor(img.naturalWidth / fw));
+ const rows = Math.min(Math.max(1, Number(rec.rows) || 1), maxRows);
+ const columns = Math.min(
+ Math.max(1, Number(rec.columns) || 1),
+ maxCols,
+ );
+ return {
+ imageUrl,
+ rows,
+ columns,
+ frameWidth: fw,
+ frameHeight: fh,
+ width: fw * columns,
+ height: fh * rows,
+ };
+ }
+
+ async function getProfile(did) {
+ const fetchedAt = profileFetchedAt.get(did) || 0;
+ const fresh = Date.now() - fetchedAt < PROFILE_TTL_MS;
+ if (profileCache.has(did) && fresh) return profileCache.get(did);
+ if (profilePending.has(did)) return profilePending.get(did);
+
+ const promise = (async () => {
+ let handle = did;
+ let pds = null;
+ try {
+ const r = await fetch(
+ `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(did)}`,
+ );
+ if (r.ok) {
+ const d = await r.json();
+ handle = d.handle || did;
+ pds = d.pds || null;
+ }
+ } catch (e) {
+ console.warn("miniDoc resolve failed", did, e);
+ }
+
+ let sprite = null;
+ if (pds) {
+ try {
+ sprite = await fetchSprite(pds, did);
+ if (sprite) console.log("[pets] rpg sprite found for", handle);
+ } catch (e) {
+ // 404s for users without a sprite are normal — only log other errors
+ if (!String(e).includes("404"))
+ console.warn("sprite fetch error", did, e);
+ }
+ }
+
+ const profile = { handle, pds, sprite };
+ profileCache.set(did, profile);
+ profileFetchedAt.set(did, Date.now());
+ return profile;
+ })();
+
+ profilePending.set(did, promise);
+ promise.finally(() => profilePending.delete(did));
+ return promise;
+ }
+
+ function spriteIdentity(s) {
+ if (!s) return null;
+ return `${s.imageUrl}|${s.rows}x${s.columns}|${s.frameWidth}x${s.frameHeight}`;
+ }
+
+ /* ---------- Bang-command scene effects ---------- */
+
+ const fxState = {
+ party: false,
+ snow: false,
+ rain: false,
+ confetti: false,
+ hearts: false,
+ quake: false,
+ barrelroll: false,
+ };
+
+ function triggerRain() {
+ if (fxState.rain) return;
+ fxState.rain = true;
+ const overlay = document.createElement("div");
+ overlay.className = "fx-rain";
+ document.body.appendChild(overlay);
+ for (let i = 0; i < 160; i++) {
+ const drop = document.createElement("div");
+ drop.className = "raindrop";
+ drop.style.left = `${Math.random() * 100}%`;
+ drop.style.height = `${10 + Math.random() * 18}px`;
+ drop.style.animationDuration = `${0.6 + Math.random() * 0.8}s`;
+ drop.style.animationDelay = `${Math.random() * 8}s`;
+ drop.style.opacity = `${0.45 + Math.random() * 0.45}`;
+ overlay.appendChild(drop);
+ }
+ setTimeout(() => {
+ overlay.remove();
+ fxState.rain = false;
+ }, 12000);
+ }
+
+ function triggerParty() {
+ if (fxState.party) return;
+ fxState.party = true;
+ const overlay = document.createElement("div");
+ overlay.className = "fx-party";
+ overlay.innerHTML = `
+
+
+
+ `;
+ document.body.appendChild(overlay);
+ setTimeout(() => {
+ overlay.remove();
+ fxState.party = false;
+ }, 7800);
+ }
+
+ function triggerSnow() {
+ if (fxState.snow) return;
+ fxState.snow = true;
+ const overlay = document.createElement("div");
+ overlay.className = "fx-snow";
+ document.body.appendChild(overlay);
+ for (let i = 0; i < 90; i++) {
+ const flake = document.createElement("div");
+ flake.className = "snowflake";
+ flake.textContent = "❄";
+ flake.style.left = `${Math.random() * 100}%`;
+ flake.style.fontSize = `${8 + Math.random() * 22}px`;
+ flake.style.animationDuration = `${4 + Math.random() * 7}s`;
+ flake.style.animationDelay = `${Math.random() * 6}s`;
+ flake.style.opacity = `${0.5 + Math.random() * 0.5}`;
+ flake.style.setProperty("--sway", `${(Math.random() - 0.5) * 240}px`);
+ overlay.appendChild(flake);
+ }
+ setTimeout(() => {
+ overlay.remove();
+ fxState.snow = false;
+ }, 14500);
+ }
+
+ function triggerConfetti() {
+ if (fxState.confetti) return;
+ fxState.confetti = true;
+ const colors = [
+ "#ff5577",
+ "#5577ff",
+ "#ffd93d",
+ "#9bbf2f",
+ "#ff8e3c",
+ "#c84fff",
+ "#3ad4ff",
+ "#ffffff",
+ "#ff3a8e",
+ ];
+ const overlay = document.createElement("div");
+ overlay.className = "fx-confetti";
+ document.body.appendChild(overlay);
+ for (let i = 0; i < 150; i++) {
+ const c = document.createElement("div");
+ c.className = "confetti-piece";
+ c.style.left = `${Math.random() * 100}%`;
+ c.style.background =
+ colors[Math.floor(Math.random() * colors.length)];
+ c.style.width = `${5 + Math.random() * 7}px`;
+ c.style.height = `${9 + Math.random() * 13}px`;
+ c.style.animationDuration = `${2.5 + Math.random() * 3.5}s`;
+ c.style.animationDelay = `${Math.random() * 1.8}s`;
+ c.style.setProperty("--sway", `${(Math.random() - 0.5) * 320}px`);
+ c.style.setProperty(
+ "--spin",
+ `${(Math.random() < 0.5 ? -1 : 1) * (360 + Math.random() * 1080)}deg`,
+ );
+ overlay.appendChild(c);
+ }
+ setTimeout(() => {
+ overlay.remove();
+ fxState.confetti = false;
+ }, 8500);
+ }
+
+ function triggerHearts() {
+ if (fxState.hearts) return;
+ fxState.hearts = true;
+ const overlay = document.createElement("div");
+ overlay.className = "fx-hearts";
+ document.body.appendChild(overlay);
+ const hearts = ["❤", "💖", "💗", "💕", "💓", "🩷"];
+ for (let i = 0; i < 60; i++) {
+ const h = document.createElement("div");
+ h.className = "heart-piece";
+ h.textContent = hearts[Math.floor(Math.random() * hearts.length)];
+ h.style.left = `${Math.random() * 100}%`;
+ h.style.fontSize = `${18 + Math.random() * 28}px`;
+ h.style.animationDuration = `${3.5 + Math.random() * 3}s`;
+ h.style.animationDelay = `${Math.random() * 2.5}s`;
+ h.style.setProperty("--sway", `${(Math.random() - 0.5) * 200}px`);
+ h.style.setProperty("--end-scale", `${0.8 + Math.random() * 0.6}`);
+ overlay.appendChild(h);
+ }
+ setTimeout(() => {
+ overlay.remove();
+ fxState.hearts = false;
+ }, 7500);
+ }
+
+ function triggerQuake() {
+ if (fxState.quake) return;
+ fxState.quake = true;
+ const stageEl = document.getElementById("stage");
+ stageEl.classList.add("fx-quake");
+ setTimeout(() => {
+ stageEl.classList.remove("fx-quake");
+ fxState.quake = false;
+ }, 600);
+ }
+
+ function triggerSpawnStars(x, y) {
+ const overlay = document.createElement("div");
+ overlay.className = "fx-spawn-stars";
+ overlay.style.left = x + "px";
+ overlay.style.top = y + "px";
+ document.body.appendChild(overlay);
+ const emojis = ["✨", "⭐", "🌟", "💫", "⚡", "💥", "🎉", "🔥"];
+ for (let i = 0; i < 14; i++) {
+ const angle = Math.random() * Math.PI * 2;
+ const dist = 30 + Math.random() * 80;
+ const dx = Math.cos(angle) * dist;
+ const dy = Math.sin(angle) * dist;
+ const star = document.createElement("div");
+ star.className = "star-particle";
+ star.textContent = emojis[Math.floor(Math.random() * emojis.length)];
+ star.style.fontSize = `${14 + Math.random() * 22}px`;
+ star.style.setProperty("--star-dx", dx + "px");
+ star.style.setProperty("--star-dy", dy + "px");
+ star.style.animationDuration = 0.5 + Math.random() * 0.6 + "s";
+ star.style.left = "0px";
+ star.style.top = "0px";
+ overlay.appendChild(star);
+ }
+ setTimeout(() => overlay.remove(), 1200);
+ }
+
+ /* ---------- Badge checking (repos-based access control) ---------- */
+
+ const badgeCheckCache = new Map(); // chatter DID → boolean
+ let _reposBadgedDids = null; // Promise> | null
+ let _reposBadgedDidsAt = 0;
+ const BADGE_TTL_MS = 5 * 60 * 1000;
+
+ async function resolveHandleToDid(handle) {
+ try {
+ const r = await fetch(
+ `https://slingshot.microcosm.blue/xrpc/blue.microcosm.identity.resolveMiniDoc?identifier=${encodeURIComponent(handle)}`,
+ );
+ if (!r.ok) return null;
+ const d = await r.json();
+ return d.did || null;
+ } catch {
+ return null;
+ }
+ }
+
+ async function getBadgedDidsForRepo(repoHandle) {
+ const issuerDid = await resolveHandleToDid(repoHandle);
+ if (!issuerDid) return new Set();
+ const profile = await getProfile(issuerDid);
+ const pds = profile?.pds;
+ if (!pds) return new Set();
+ const out = new Set();
+ let cursor = "";
+ for (let page = 0; page < 5; page++) {
+ const url =
+ `${pds.replace(/\/$/, "")}/xrpc/com.atproto.repo.listRecords` +
+ `?repo=${encodeURIComponent(issuerDid)}` +
+ `&collection=place.stream.badge.issuance&limit=100` +
+ (cursor ? `&cursor=${encodeURIComponent(cursor)}` : "");
+ let data;
+ try {
+ const r = await fetch(url);
+ if (!r.ok) break;
+ data = await r.json();
+ } catch {
+ break;
+ }
+ for (const rec of data.records || []) {
+ if (
+ rec.value &&
+ typeof rec.value.did === "string" &&
+ rec.value.did.startsWith("did:")
+ ) {
+ out.add(rec.value.did);
+ }
+ }
+ cursor = data.cursor || "";
+ if (!cursor) break;
+ }
+ return out;
+ }
+
+ async function getAllBadgedDids() {
+ if (_reposBadgedDids && Date.now() - _reposBadgedDidsAt < BADGE_TTL_MS)
+ return _reposBadgedDids;
+ const promise = (async () => {
+ const all = new Set();
+ if (!REPOS || REPOS.length === 0) return all;
+ for (const repo of REPOS) {
+ const set = await getBadgedDidsForRepo(repo.trim());
+ for (const did of set) all.add(did);
+ }
+ console.log(
+ `[pets] badged DIDs across ${REPOS.length} repos: ${all.size}`,
+ );
+ return all;
+ })();
+ _reposBadgedDids = promise;
+ _reposBadgedDidsAt = Date.now();
+ return promise;
+ }
+
+ async function chatterIsBadged(did) {
+ if (STREAMER_DID && did === STREAMER_DID) return true;
+ if (!REPOS || REPOS.length === 0) return true;
+ if (badgeCheckCache.has(did)) return badgeCheckCache.get(did);
+ const badged = await getAllBadgedDids();
+ const ok = badged.has(did);
+ if (ok) badgeCheckCache.set(did, true);
+ return ok;
+ }
+
+ /* ---------- Streamer-only and badge-gated actions ---------- */
+
+ function triggerBarrelRoll() {
+ if (fxState.barrelroll) return;
+ fxState.barrelroll = true;
+ document.body.classList.add("fx-barrelroll");
+ setTimeout(() => {
+ document.body.classList.remove("fx-barrelroll");
+ fxState.barrelroll = false;
+ }, 1450);
+ }
+
+ async function triggerRefresh(chatterDid) {
+ const allowed = await chatterIsBadged(chatterDid);
+ if (!allowed) {
+ console.log(
+ "[pets] !refresh denied — chatter lacks required badge",
+ chatterDid,
+ );
+ return;
+ }
+ saveState();
+ setTimeout(() => location.reload(), 200);
+ }
+
+ async function triggerClearAll(chatterDid) {
+ const allowed = await chatterIsBadged(chatterDid);
+ if (!allowed) {
+ console.log(
+ "[pets] !clearall denied — chatter lacks required badge",
+ chatterDid,
+ );
+ return;
+ }
+ try {
+ localStorage.clear();
+ } catch {}
+ _suppressSave = true;
+ setTimeout(() => location.reload(), 200);
+ }
+
+ async function triggerHardReset(chatterDid) {
+ if (!STREAMER_DID || chatterDid !== STREAMER_DID) {
+ console.log(
+ "[pets] !hardreset denied — only the streamer can run this",
+ chatterDid,
+ );
+ return;
+ }
+ try {
+ localStorage.clear();
+ } catch {}
+ try {
+ sessionStorage.clear();
+ } catch {}
+ try {
+ if (window.caches) {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((k) => caches.delete(k)));
+ }
+ } catch {}
+ try {
+ if (indexedDB.databases) {
+ const dbs = await indexedDB.databases();
+ await Promise.all(
+ dbs.map(
+ (d) =>
+ d.name &&
+ new Promise((res) => {
+ const req = indexedDB.deleteDatabase(d.name);
+ req.onsuccess = req.onerror = req.onblocked = () => res();
+ }),
+ ),
+ );
+ }
+ } catch {}
+ _suppressSave = true;
+ setTimeout(() => {
+ const u = new URL(location.href);
+ u.searchParams.set("_t", String(Date.now()));
+ location.replace(u.toString());
+ }, 250);
+ }
+
+ /* ---------- Persistence (so refresh doesn't lose state) ---------- */
+
+ const STATE_KEY = "streampets-state-v1";
+ const STATE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
+
+ let _saveStateTimer = null;
+ let _suppressSave = false;
+ function saveStateDebounced() {
+ clearTimeout(_saveStateTimer);
+ _saveStateTimer = setTimeout(saveState, 1500);
+ }
+
+ function saveState() {
+ if (_suppressSave) return;
+ try {
+ const arr = [];
+ const nowPerf = performance.now();
+ const wallNow = Date.now();
+ for (const [did, pet] of pets) {
+ arr.push({
+ did,
+ handle: pet.handle,
+ x: pet.x,
+ dir: pet.dir,
+ depthScale: pet.depthScale,
+ stretchX: pet.stretchX,
+ stretchY: pet.stretchY,
+ speedMul: pet.speedMul,
+ bulgeLevel: pet.bulgeLevel,
+ blurLevel: pet.blurLevel,
+ typeOverride: petTypeOverrides.get(did) || null,
+ lastMessageWall: wallNow - (nowPerf - pet.lastMessage),
+ sprite: pet.mode === "sprite" ? pet.sprite : null,
+ });
+ }
+ localStorage.setItem(
+ STATE_KEY,
+ JSON.stringify({ ts: wallNow, pets: arr }),
+ );
+ } catch (e) {
+ /* localStorage may be disabled */
+ }
+ }
+
+ function restoreState() {
+ try {
+ const raw = localStorage.getItem(STATE_KEY);
+ if (!raw) return;
+ const data = JSON.parse(raw);
+ if (!data.pets || Date.now() - data.ts > STATE_TTL_MS) return;
+ for (const saved of data.pets) {
+ if (pets.has(saved.did)) continue;
+ if (saved.typeOverride)
+ petTypeOverrides.set(saved.did, saved.typeOverride);
+ let spec;
+ if (saved.typeOverride) {
+ spec = pickPet(saved.did, saved.handle, null);
+ } else if (saved.sprite) {
+ spec = {
+ mode: "sprite",
+ sprite: saved.sprite,
+ scale: 1.0,
+ speed: 42,
+ };
+ } else {
+ spec = pickPet(saved.did, saved.handle, null);
+ }
+ const pet = new Pet(
+ spec,
+ document.getElementById("stage"),
+ saved.handle,
+ );
+ pet.x = typeof saved.x === "number" ? saved.x : pet.x;
+ pet.dir = saved.dir === -1 ? -1 : 1;
+ pet.depthScale = saved.depthScale || 1;
+ pet.stretchX = saved.stretchX || 1;
+ pet.stretchY = saved.stretchY || 1;
+ pet.speedMul = saved.speedMul || 1;
+ pet.el.style.setProperty("--anim-speed", pet.speedMul);
+ if (saved.bulgeLevel) pet.setBulgeLevel(saved.bulgeLevel);
+ if (saved.blurLevel) pet.setBlurLevel(saved.blurLevel);
+ pet.applyTransform();
+ pet.el.classList.remove("spawning");
+ pets.set(saved.did, pet);
+ // Seed profile cache so subsequent messages don't re-resolve
+ profileCache.set(saved.did, {
+ handle: saved.handle,
+ pds: null,
+ sprite: saved.sprite || null,
+ });
+ }
+ console.log(
+ `[pets] restored ${data.pets.length} pets from localStorage`,
+ );
+ } catch (e) {
+ console.warn("restoreState failed", e);
+ }
+ }
+
+ window.addEventListener("beforeunload", saveState);
+
+ /* ---------- Pet manager ---------- */
+
+ const stage = document.getElementById("stage");
+ const statusEl = document.getElementById("status");
+ const pets = new Map();
+ const commandCooldownUntil = new Map(); // did → performance.now() until which commands are blocked
+ const MAX_PETS = 30;
+
+ function evictIfNeeded() {
+ if (pets.size < MAX_PETS) return;
+ let oldestDid = null;
+ let oldestTime = Infinity;
+ for (const [did, pet] of pets) {
+ if (pet.lastMessage < oldestTime) {
+ oldestTime = pet.lastMessage;
+ oldestDid = did;
+ }
+ }
+ if (oldestDid) {
+ pets.get(oldestDid).dispose();
+ pets.delete(oldestDid);
+ }
+ }
+
+ async function handleChatMessage(authorDid, record) {
+ const text = String(record.text || "")
+ .trim()
+ .toLowerCase();
+
+ // `reload` / `refresh` — drop cached profile + pet so a freshly uploaded sprite
+ // re-resolves on the next handler call.
+ if (
+ /^(reload|reloadsprite|reload\s*sprite|refresh|respawn)$/i.test(text)
+ ) {
+ profileCache.delete(authorDid);
+ profileFetchedAt.delete(authorDid);
+ badgeCheckCache.delete(authorDid);
+ if (pets.has(authorDid)) {
+ pets.get(authorDid).dispose();
+ pets.delete(authorDid);
+ }
+ console.log("[pets] reloaded", authorDid);
+ }
+
+ // `reload @other-handle` — drop another chatter's profile + pet so their newly
+ // uploaded sprite resolves on their next message. Mod-only (badge-gated) per
+ // streamer feedback: actions that affect a different user need permission.
+ const reloadOtherMatch = (record.text || "").match(
+ /^\s*(?:reload|reloadsprite|reload\s*sprite|refresh|respawn)\s+(?:rpg\.actor\s+sprite\s+for\s+)?@?([A-Za-z0-9\.\-_]+)\s*$/i,
+ );
+ if (
+ reloadOtherMatch &&
+ reloadOtherMatch[1].toLowerCase() !== "sprite"
+ ) {
+ if (await chatterIsBadged(authorDid)) {
+ const target = reloadOtherMatch[1].toLowerCase();
+ for (const [did, p] of pets) {
+ const h = (p.handle || "").toLowerCase();
+ if (
+ h === target ||
+ h.split(".")[0] === target ||
+ h.startsWith(target + ".")
+ ) {
+ profileCache.delete(did);
+ profileFetchedAt.delete(did);
+ p.dispose();
+ pets.delete(did);
+ console.log("[pets] reloaded target", did, "@" + h);
+ break;
+ }
+ }
+ } else {
+ console.log(
+ "[pets] reload @target denied — chatter has no badge",
+ authorDid,
+ );
+ }
+ }
+
+ const profile = await getProfile(authorDid);
+
+ let pet = pets.get(authorDid);
+ // If the user's sprite identity changed (uploaded a new one, fixed a broken
+ // one, removed it), drop the old pet and rebuild from the new spec.
+ if (pet) {
+ const oldId =
+ pet.mode === "sprite" ? spriteIdentity(pet.sprite) : null;
+ const newId = spriteIdentity(profile.sprite);
+ if (oldId !== newId) {
+ pet.dispose();
+ pets.delete(authorDid);
+ pet = null;
+ }
+ }
+ if (!pet) {
+ evictIfNeeded();
+ const spec = pickPet(authorDid, profile.handle, profile.sprite);
+ pet = new Pet(spec, stage, profile.handle);
+ pets.set(authorDid, pet);
+ triggerSpawnStars(
+ Math.max(10, Math.min(window.innerWidth - 10, pet.x)),
+ window.innerHeight - 90,
+ );
+ } else {
+ pet.setHandle(profile.handle);
+ }
+
+ const CMD = {
+ // Aliases: en + de + nl
+ left: new Set(["left", "links", "<", "⬅️"]),
+ right: new Set(["right", "rechts", ">", "➡️"]),
+ up: new Set([
+ "up",
+ "hoch",
+ "omhoog",
+ "boven",
+ "smaller",
+ "kleiner",
+ "^",
+ "⬆️",
+ "🔼",
+ ]),
+ down: new Set([
+ "down",
+ "runter",
+ "omlaag",
+ "beneden",
+ "bigger",
+ "größer",
+ "grösser",
+ "groter",
+ "v",
+ "⬇️",
+ "🔽",
+ ]),
+ faster: new Set([
+ "speedup",
+ "fast",
+ "faster",
+ "run",
+ "schneller",
+ "sneller",
+ "vlugger",
+ "⏩",
+ ]),
+ slower: new Set([
+ "slowdown",
+ "slow",
+ "slower",
+ "langsamer",
+ "langzamer",
+ "langzaam",
+ "traag",
+ "⏪",
+ ]),
+ jump: new Set(["jump", "spring", "springen", "springt", "hop", "🦘"]),
+ dance: new Set([
+ "dance",
+ "tanz",
+ "tanze",
+ "tanzen",
+ "dans",
+ "dansen",
+ "💃",
+ "🕺",
+ ]),
+ fart: new Set([
+ "fart",
+ "farts",
+ "farted",
+ "farting",
+ "scheet",
+ "scheten",
+ "furz",
+ "💨",
+ ]),
+ wider: new Set([
+ "wide",
+ "wider",
+ "breit",
+ "breiter",
+ "breed",
+ "breder",
+ "wijd",
+ "wijder",
+ "stretch",
+ ]),
+ narrower: new Set([
+ "narrow",
+ "narrower",
+ "schmal",
+ "schmaler",
+ "smal",
+ "thin",
+ "dun",
+ ]),
+ taller: new Set([
+ "tall",
+ "taller",
+ "lang",
+ "länger",
+ "laenger",
+ "hoog",
+ "hoger",
+ ]),
+ shorter: new Set([
+ "short",
+ "shorter",
+ "flach",
+ "flacher",
+ "kurz",
+ "kürzer",
+ "kort",
+ "korter",
+ "laag",
+ "lager",
+ ]),
+ bulge: new Set([
+ "bulge",
+ "bulgy",
+ "fisheye",
+ "fisch",
+ "bulle",
+ "bolle",
+ "bol",
+ ]),
+ unbulge: new Set(["unbulge", "debulge", "flatten", "unfish"]),
+ blur: new Set(["blur", "blurry", "unsharp", "verschwommen", "wazig"]),
+ unblur: new Set([
+ "unblur",
+ "sharp",
+ "sharpen",
+ "focus",
+ "scharp",
+ "scherp",
+ ]),
+ };
+
+ // Temporarily replace the handle pill with the redacted chat message
+ pet.showMessage(record.text || "");
+
+ // Pet-type pick: must be the *whole* message (optionally with a leading !).
+ // Casual mentions ("did you turn me into a duck") used to trigger this and
+ // accidentally morph the speaker — that was a footgun, hence the strict match.
+ // 'random' (also whole-message-only) clears the override.
+ const TYPE_WORDS = {
+ cat: "cat",
+ kitty: "cat",
+ kitten: "cat",
+ katze: "cat",
+ kat: "cat",
+ dog: "dog",
+ doggo: "dog",
+ puppy: "dog",
+ hund: "dog",
+ hond: "dog",
+ bunny: "bunny",
+ rabbit: "bunny",
+ hase: "bunny",
+ konijn: "bunny",
+ duck: "duck",
+ ente: "duck",
+ eend: "duck",
+ hamster: "hamster",
+ goose: "goose",
+ gans: "goose",
+ };
+ const typeMatch = text.match(/^\s*!?([a-zäöü]+)\s*$/i);
+ let chosenType = null;
+ if (typeMatch) {
+ const w = typeMatch[1].toLowerCase();
+ if (TYPE_WORDS[w]) chosenType = TYPE_WORDS[w];
+ else if (w === "random") {
+ petTypeOverrides.delete(authorDid);
+ chosenType = "__rebuild__";
+ }
+ }
+ if (chosenType) {
+ if (chosenType !== "__rebuild__")
+ petTypeOverrides.set(authorDid, chosenType);
+ const oldX = pet.x,
+ oldDir = pet.dir;
+ const oldSpeedMul = pet.speedMul,
+ oldDepthScale = pet.depthScale;
+ const oldStretchX = pet.stretchX,
+ oldStretchY = pet.stretchY;
+ const oldBulge = pet.bulgeLevel;
+ const oldBlur = pet.blurLevel;
+ pet.dispose();
+ pets.delete(authorDid);
+ const profileNow = profileCache.get(authorDid) || profile;
+ const spec = pickPet(authorDid, profileNow.handle, profileNow.sprite);
+ pet = new Pet(spec, stage, profileNow.handle);
+ pet.x = oldX;
+ pet.dir = oldDir;
+ pet.speedMul = oldSpeedMul;
+ pet.depthScale = oldDepthScale;
+ pet.stretchX = oldStretchX;
+ pet.stretchY = oldStretchY;
+ pet.el.style.setProperty("--anim-speed", pet.speedMul);
+ if (oldBulge) pet.setBulgeLevel(oldBulge);
+ if (oldBlur) pet.setBlurLevel(oldBlur);
+ pet.applyTransform();
+ pets.set(authorDid, pet);
+ triggerSpawnStars(
+ Math.max(10, Math.min(window.innerWidth - 10, pet.x)),
+ window.innerHeight - 90,
+ );
+ saveStateDebounced();
+ }
+
+ // Per-chatter command rate limit — keeps one person from monopolizing the scene.
+ // Plain chat (no command) still pulses the heart, no cooldown.
+ // Recovery commands (!reset, !refresh) bypass the limit so users can always
+ // dig themselves out of an effects pileup.
+ const RECOVERY_CMD_RE =
+ /(!reset|!refresh|!clearall|!factoryreset|!hardreset)\b/i;
+ if (RECOVERY_CMD_RE.test(text)) {
+ if (/!reset\b/i.test(text)) pet.resetAll();
+ if (/!refresh\b/i.test(text)) triggerRefresh(authorDid);
+ if (/!(clearall|factoryreset)\b/i.test(text))
+ triggerClearAll(authorDid);
+ if (/!hardreset\b/i.test(text)) triggerHardReset(authorDid);
+ return;
+ }
+ const ALL_COMMAND_RE =
+ /(!party|!snow|!rain|!confetti|!hearts?|!(?:quake|earthquake|shake)|!shrink(?:ray)?|!grow(?:ray)?|!pet\b|barrel\s*roll)\b/i;
+ const looksLikeCommand =
+ ALL_COMMAND_RE.test(text) ||
+ /\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)/i.test(
+ text,
+ ) ||
+ /\b(links|rechts|hoch|runter|größer|grösser|kleiner|schneller|langsamer|spring|tanz|scheet|scheten|furz|breit|schmal|kurz|lang)/i.test(
+ text,
+ ) ||
+ /\b(omhoog|omlaag|sneller|langzamer|dans|dansen|wijd|smal|hoger|laag|lager|breed)/i.test(
+ text,
+ ) ||
+ /(⬅️|➡️|⬆️|⬇️|🔼|🔽|⏩|⏪|🦘|💃|🕺|💨)/.test(text);
+ const COMMAND_COOLDOWN_MS = 10000; // 10 seconds
+ const now = performance.now();
+ if (looksLikeCommand) {
+ const until = commandCooldownUntil.get(authorDid) || 0;
+ if (now < until) {
+ console.log(
+ "[pets] rate limited",
+ authorDid,
+ `(${Math.round(until - now)}ms remaining)`,
+ );
+ // Visual indicator: red glow for 1 second
+ pet.el.classList.add("rate-limited");
+ setTimeout(() => pet.el.classList.remove("rate-limited"), 1000);
+ return;
+ }
+ commandCooldownUntil.set(authorDid, now + COMMAND_COOLDOWN_MS);
+ }
+
+ // !pet @target — friendly pat on the head, no badge required.
+ const petMatch = text.match(/!pet\s+@?([\w\.\-]+)/i);
+ if (petMatch) {
+ const target = petMatch[1].toLowerCase();
+ for (const p of pets.values()) {
+ const h = (p.handle || "").toLowerCase();
+ if (
+ h === target ||
+ h.split(".")[0] === target ||
+ h.startsWith(target + ".")
+ ) {
+ p.petPat();
+ break;
+ }
+ }
+ }
+
+ // !shrink @target / !grow @target — affect another chatter's pet by handle.
+ // Mod-only (badge-gated) per streamer: cross-user commands need mod perms.
+ const shrinkRayMatch = text.match(
+ /!(shrink|shrinkray|grow|growray)\s+@?([\w\.\-]+)/i,
+ );
+ if (shrinkRayMatch) {
+ if (await chatterIsBadged(authorDid)) {
+ const isShrink = /^shrink/i.test(shrinkRayMatch[1]);
+ const target = shrinkRayMatch[2].toLowerCase();
+ let foundPet = null;
+ for (const p of pets.values()) {
+ const h = (p.handle || "").toLowerCase();
+ if (
+ h === target ||
+ h.split(".")[0] === target ||
+ h.startsWith(target + ".")
+ ) {
+ foundPet = p;
+ break;
+ }
+ }
+ if (foundPet) {
+ foundPet.setDepthScale(
+ foundPet.depthScale * (isShrink ? 1 / 1.5 : 1.5),
+ );
+ foundPet.pulseHeart && foundPet.pulseHeart();
+ } else {
+ console.log("[pets] !shrink/!grow target not on stage:", target);
+ }
+ } else {
+ console.log(
+ "[pets] !shrink/!grow denied — chatter has no badge",
+ authorDid,
+ );
+ }
+ }
+
+ // === Mid-tier: bang commands (badge-gated if repos configured) ===
+ const bangCmds = [];
+ if (/!party\b/i.test(text)) bangCmds.push(["party", triggerParty]);
+ if (/!snow\b/i.test(text)) bangCmds.push(["snow", triggerSnow]);
+ if (/!rain\b/i.test(text)) bangCmds.push(["rain", triggerRain]);
+ if (/!confetti\b/i.test(text))
+ bangCmds.push(["confetti", triggerConfetti]);
+ if (/!hearts?\b/i.test(text)) bangCmds.push(["hearts", triggerHearts]);
+ if (/!(quake|earthquake|shake)\b/i.test(text))
+ bangCmds.push(["quake", triggerQuake]);
+ if (/\bbarrel\s*roll\b/i.test(text))
+ bangCmds.push(["barrelroll", triggerBarrelRoll]);
+
+ if (bangCmds.length > 0) {
+ const allowed = await chatterIsBadged(authorDid);
+ if (!allowed) {
+ console.log(
+ "[pets] bang command denied — chatter lacks required badge",
+ authorDid,
+ );
+ } else {
+ for (const [, fn] of bangCmds) fn();
+ }
+ }
+
+ // Tokenize: split on whitespace, strip ASCII edge-punctuation
+ const tokens = text
+ .split(/\s+/)
+ .map((t) =>
+ t.replace(/^[!?.,;:'"()\[\]{}*]+|[!?.,;:'"()\[\]{}*]+$/g, ""),
+ );
+ const counts = {};
+ for (let i = 0; i < tokens.length; i++) {
+ const rawTok = tokens[i];
+ if (!rawTok) continue;
+ const m = rawTok.match(/^(.+?)x(\d+)$/);
+ const word = m ? m[1] : rawTok;
+ let repeat = m
+ ? Math.max(1, Math.min(LIMITS.xN, parseInt(m[2], 10)))
+ : 1;
+ // Look-ahead: if this token isn't already a multiplier and the next token is a pure number, use it.
+ if (!m && i + 1 < tokens.length && /^x?\d+$/i.test(tokens[i + 1])) {
+ const n = parseInt(tokens[i + 1].replace(/^x/i, ""), 10);
+ if (n > 0) {
+ repeat = Math.min(LIMITS.xN, n);
+ i++; // consume the number token
+ }
+ }
+ let cmd = null;
+ for (const [k, set] of Object.entries(CMD)) {
+ if (set.has(word)) {
+ cmd = k;
+ break;
+ }
+ }
+ if (!cmd) continue;
+ counts[cmd] = (counts[cmd] || 0) + repeat;
+ }
+ // Animation commands stay reasonable to avoid browser pain; state commands flow through to setters which clamp themselves.
+ if (counts.jump) counts.jump = Math.min(LIMITS.jumpQueue, counts.jump);
+ if (counts.dance) counts.dance = Math.min(1, counts.dance);
+ if (counts.fart) counts.fart = Math.min(LIMITS.fartQueue, counts.fart);
+
+ // Apply
+ if (counts.left) pet.setDirection(-1);
+ if (counts.right) pet.setDirection(1);
+ if (counts.up)
+ pet.setDepthScale(pet.depthScale / Math.pow(1.25, counts.up));
+ if (counts.down)
+ pet.setDepthScale(pet.depthScale * Math.pow(1.25, counts.down));
+ if (counts.faster)
+ pet.setSpeedMul(pet.speedMul * Math.pow(1.5, counts.faster));
+ if (counts.slower)
+ pet.setSpeedMul(pet.speedMul / Math.pow(1.5, counts.slower));
+ if (counts.jump) {
+ for (let i = 0; i < counts.jump; i++)
+ setTimeout(() => pet.jump(), i * 850);
+ }
+ if (counts.dance) pet.dance();
+ if (counts.fart) {
+ for (let i = 0; i < counts.fart; i++)
+ setTimeout(() => pet.fart(), i * 600);
+ }
+ if (counts.wider)
+ pet.setStretchX(pet.stretchX * Math.pow(1.25, counts.wider));
+ if (counts.narrower)
+ pet.setStretchX(pet.stretchX / Math.pow(1.25, counts.narrower));
+ if (counts.taller)
+ pet.setStretchY(pet.stretchY * Math.pow(1.25, counts.taller));
+ if (counts.shorter)
+ pet.setStretchY(pet.stretchY / Math.pow(1.25, counts.shorter));
+ if (counts.bulge) pet.setBulgeLevel(pet.bulgeLevel + counts.bulge);
+ if (counts.unbulge) pet.setBulgeLevel(0);
+ if (counts.blur) pet.setBlurLevel(pet.blurLevel + counts.blur);
+ if (counts.unblur) pet.setBlurLevel(0);
+
+ // Catch-all: substring "fart" inside a sentence still fires once if no fart token matched.
+ if (!counts.fart && (/\bfart/i.test(text) || text.includes("💨"))) {
+ pet.fart();
+ counts.fart = 1;
+ }
+
+ // (No more pulseHeart on plain chat — the message bubble now carries the
+ // "I said something" signal and the heart was visual noise.)
+ }
+
+ /* ---------- Jetstream ---------- */
+
+ const params = new URLSearchParams(location.search);
+ const STREAMER_DID = params.get("streamer");
+ const REPOS = params.get("repos")
+ ? params
+ .get("repos")
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ : null;
+ const SHOW_STATUS = params.get("status") === "1";
+ const SAFE_LIMITS =
+ params.get("limits") === "safe" || params.get("safe") === "1";
+ const CONSERVATION = params.get("conservation") === "1";
+
+ const LIMITS = SAFE_LIMITS
+ ? {
+ speedMul: [0.2, 5],
+ depthScale: [0.3, 2.2],
+ stretchX: [0.4, 3.5],
+ stretchY: [0.4, 3.5],
+ bulgeLevel: [0, 8],
+ blurLevel: [0, 6],
+ xN: 10,
+ jumpQueue: 5,
+ fartQueue: 5,
+ }
+ : {
+ speedMul: [0.02, 50],
+ depthScale: [0.005, 80],
+ stretchX: [0.005, 80],
+ stretchY: [0.005, 80],
+ bulgeLevel: [0, 30],
+ blurLevel: [0, 20],
+ xN: 100,
+ jumpQueue: 50,
+ fartQueue: 50,
+ };
+
+ if (SHOW_STATUS) statusEl.classList.remove("hidden");
+
+ function setStatus(s) {
+ if (SHOW_STATUS) statusEl.textContent = s;
+ console.log("[pets]", s);
+ }
+
+ const JETSTREAM_HOSTS = [
+ "jetstream2.us-east.bsky.network",
+ "jetstream1.us-east.bsky.network",
+ "jetstream2.us-west.bsky.network",
+ "jetstream1.us-west.bsky.network",
+ ];
+ let hostIdx = 0;
+ let backoff = 1000;
+
+ function connectJetstream() {
+ const host = JETSTREAM_HOSTS[hostIdx % JETSTREAM_HOSTS.length];
+ const url = `wss://${host}/subscribe?wantedCollections=place.stream.chat.message`;
+ setStatus(`connecting → ${host}`);
+ let ws;
+ try {
+ ws = new WebSocket(url);
+ } catch (e) {
+ console.warn("ws construct error", e);
+ scheduleReconnect();
+ return;
+ }
+
+ ws.onopen = () => {
+ setStatus(
+ `live · ${host}${STREAMER_DID ? " · filter on" : " · all streams"}${REPOS ? " · repos=" + REPOS.join(",") : ""}`,
+ );
+ backoff = 1000;
+ };
+
+ ws.onmessage = (ev) => {
+ let msg;
+ try {
+ msg = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+ if (msg.kind !== "commit") return;
+ const c = msg.commit;
+ if (!c || c.operation !== "create") return;
+ if (c.collection !== "place.stream.chat.message") return;
+ const record = c.record;
+ if (!record || typeof record !== "object") return;
+ if (STREAMER_DID && record.streamer !== STREAMER_DID) return;
+ handleChatMessage(msg.did, record);
+ };
+
+ ws.onerror = (e) => console.warn("jetstream error", e);
+
+ ws.onclose = () => {
+ setStatus("disconnected");
+ hostIdx++;
+ scheduleReconnect();
+ };
+ }
+
+ function scheduleReconnect() {
+ setTimeout(connectJetstream, backoff);
+ backoff = Math.min(backoff * 1.5, 15000);
+ }
+
+ // Build version: bumped each commit so chat can see new code arrived.
+ const BUILD_VERSION = "v0.9.0";
+ const BUILD_DATE = "2025-07-14";
+ const versionEl = document.getElementById("version");
+ if (STREAMER_DID) {
+ versionEl.textContent = `pets ${BUILD_VERSION} · ${BUILD_DATE}`;
+ } else {
+ // Visible warning so the streamer notices the filter is off — without a
+ // ?streamer=did:... URL param, every chat from every stream lands on stage.
+ versionEl.textContent = `pets ${BUILD_VERSION} · NO STREAMER FILTER — add ?streamer=did:... to the URL`;
+ versionEl.style.color = "rgba(255, 180, 80, 0.95)";
+ versionEl.style.background = "rgba(0, 0, 0, 0.55)";
+ versionEl.style.padding = "2px 6px";
+ versionEl.style.borderRadius = "4px";
+ }
+
+ // "Reloaded" toast — pops up briefly when the page (re)loads.
+ {
+ const toast = document.createElement("div");
+ toast.id = "reload-toast";
+ toast.textContent = `pets reloaded · ${BUILD_VERSION}`;
+ document.body.appendChild(toast);
+ setTimeout(() => toast.remove(), 4000);
+ }
+
+ // Hard-reset button — clears everything and reloads
+ document.getElementById('reset-btn').addEventListener('click', async () => {
+ if (!confirm('Hard reset? This clears all pet state, sprites, caches, and reloads.')) return;
+ try { localStorage.clear(); } catch {}
+ try { sessionStorage.clear(); } catch {}
+ try {
+ if (window.caches) {
+ const keys = await caches.keys();
+ await Promise.all(keys.map((k) => caches.delete(k)));
+ }
+ } catch {}
+ try {
+ if (indexedDB.databases) {
+ const dbs = await indexedDB.databases();
+ await Promise.all(dbs.map((d) => d.name && new Promise((res) => {
+ const req = indexedDB.deleteDatabase(d.name);
+ req.onsuccess = req.onerror = req.onblocked = () => res();
+ })));
+ }
+ } catch {}
+ _suppressSave = true;
+ location.reload();
+ });
+
+ restoreState();
+ connectJetstream();
+
+ /* ---------- Main loop ---------- */
+
+ // Three-stage idle fade:
+ // 0 – 10 min: full opacity
+ // 10 – 20 min: opacity 0.5
+ // 20 – 30 min: opacity 0.25
+ // > 30 min: removed
+ // Messages immediately restore full opacity.
+ const IDLE_FADE_1_MS = 10 * 60 * 1000; // 50% opacity at 10 min
+ const IDLE_FADE_2_MS = 20 * 60 * 1000; // 25% opacity at 20 min
+ const IDLE_DEATH_MS = 30 * 60 * 1000; // removed at 30 min
+
+ let last = performance.now();
+ function frame(now) {
+ const dt = Math.min(0.05, (now - last) / 1000);
+ last = now;
+ const dead = [];
+ for (const [did, p] of pets) {
+ p.tick(dt);
+ const idle = now - p.lastMessage;
+ if (idle >= IDLE_DEATH_MS) {
+ dead.push(did);
+ continue;
+ }
+ if (idle >= IDLE_FADE_2_MS) {
+ p.el.style.opacity = "0.25";
+ } else if (idle >= IDLE_FADE_1_MS) {
+ p.el.style.opacity = "0.5";
+ } else {
+ p.el.style.opacity = "1";
+ }
+ }
+ for (const did of dead) {
+ pets.get(did).dispose();
+ pets.delete(did);
+ }
+ requestAnimationFrame(frame);
+ }
+ requestAnimationFrame(frame);
+
+ window.addEventListener("resize", () => {
+ for (const p of pets.values()) {
+ p.x = Math.min(p.x, window.innerWidth - 110);
+ }
+ });