diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 0d7e6cb..0000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"3d23f89e-6e34-42f0-9960-ef93d5f29ec6","pid":88905,"procStart":"Wed Apr 29 23:16:02 2026","acquiredAt":1777506620140} \ No newline at end of file diff --git a/pets.html b/pets.html index a116b7d..d42ab6e 100644 --- a/pets.html +++ b/pets.html @@ -302,6 +302,55 @@ 100% { transform: translate(var(--sway, 0), -110vh) scale(var(--end-scale, 1.1)); opacity: 0; } } + /* !fireworks — scene-wide bursts */ + .fx-fireworks { + position: fixed; inset: 0; pointer-events: none; overflow: hidden; + z-index: 160; + animation: fx-fade-out 1s ease-out 7.5s forwards; + } + .firework-particle { + position: absolute; + width: 6px; height: 6px; + border-radius: 50%; + box-shadow: 0 0 12px 2px rgba(255,255,255,0.55); + opacity: 0; + animation-name: firework-burst; + animation-timing-function: cubic-bezier(.25, .8, .35, 1); + animation-fill-mode: forwards; + } + @keyframes firework-burst { + 0% { transform: translate(0, 0) scale(0.5); opacity: 0; } + 8% { opacity: 1; } + 100% { transform: translate(var(--dx), var(--dy)) scale(0.4); opacity: 0; } + } + + /* Per-pet smoke — wispy gray puffs from upper face */ + .smoke { + position: absolute; + top: -14px; + left: 50%; + margin-left: -35px; + width: 70px; + height: 90px; + opacity: 0; + pointer-events: none; + } + .smoke svg { width: 100%; height: 100%; overflow: visible; } + .smoke-puff { + transform-box: fill-box; + transform-origin: center; + animation-name: smoke-puff; + animation-timing-function: ease-out; + animation-fill-mode: forwards; + opacity: 0; + } + .pet.smoking .smoke { opacity: 1; } + @keyframes smoke-puff { + 0% { transform: translate(0, 0) scale(var(--smoke-start, 0.2)); opacity: 0; } + 20% { opacity: var(--smoke-peak, 0.7); } + 100% { transform: translate(var(--smoke-dx, 0), var(--smoke-dy, -55px)) scale(var(--smoke-end, 1.8)); opacity: 0; } + } + /* !quake — shakes the stage */ .fx-quake { animation: quake 0.55s linear; } @keyframes quake { @@ -325,6 +374,24 @@ 100% { translate: 0 0; } } + /* Barrel roll — Star Fox style: arc up while rotating 360 */ + .pet.rolling .pet-flip { animation: barrel-roll 0.75s cubic-bezier(.4,.0,.4,1); } + @keyframes barrel-roll { + 0% { translate: 0 0; rotate: 0deg; } + 35% { translate: 0 -32px; rotate: 130deg; } + 65% { translate: 0 -32px; rotate: 230deg; } + 100% { translate: 0 0; rotate: 360deg; } + } + + /* Teleport — fade out, JS moves the pet, fade in */ + .pet.teleporting { animation: teleport 0.7s ease-in-out; } + @keyframes teleport { + 0% { opacity: 1; } + 35% { opacity: 0; } + 65% { opacity: 0; } + 100% { opacity: 1; } + } + /* Dance — bigger sway, double spin, finale flourish */ .pet.dancing .pet-flip { animation: dance 3s cubic-bezier(.45,.0,.45,1); } @keyframes dance { @@ -591,6 +658,30 @@ const HEART_SVG = ` `; const FART_GREENS = ['#9bbf2f', '#aacc3a', '#b8d650', '#8eb02a', '#7ea020', '#c4dc66', '#a3c544', '#88a52a', '#d4e878']; +const SMOKE_GRAYS = ['#dadada', '#cccccc', '#bbbbbb', '#aaaaaa', '#c8c8c8', '#9c9c9c', '#e8e8e8']; + +function randomSmokeSvg() { + const n = 5 + Math.floor(Math.random() * 4); + const rand = (a, b) => a + Math.random() * (b - a); + let body = ''; + let maxDur = 0; + for (let i = 0; i < n; i++) { + const cx = rand(20, 50); + const cy = rand(55, 75); + const r = rand(7, 16); + const fill = SMOKE_GRAYS[Math.floor(Math.random() * SMOKE_GRAYS.length)]; + const peak = rand(0.45, 0.75); + const dx = rand(-14, 14); + const dy = rand(-35, -65); + const startScale = rand(0.15, 0.32); + const endScale = rand(1.4, 2.4); + const duration = rand(1.5, 2.8); + const delay = rand(0, 0.6); + if (duration + delay > maxDur) maxDur = duration + delay; + body += ``; + } + return { svg: `${body}`, durationMs: maxDur * 1000 + 100 }; +} function randomFartSvg() { const n = 4 + Math.floor(Math.random() * 5); // 4–8 puffs @@ -723,6 +814,9 @@ class Pet { this.fartEl = document.createElement('div'); this.fartEl.className = 'fart'; + this.smokeEl = document.createElement('div'); + this.smokeEl.className = 'smoke'; + this.labelEl = document.createElement('div'); this.labelEl.className = 'pet-label'; @@ -730,6 +824,7 @@ class Pet { this.el.appendChild(this.zzzEl); this.el.appendChild(this.heartEl); this.el.appendChild(this.fartEl); + this.el.appendChild(this.smokeEl); this.el.appendChild(this.labelEl); this.setHandle(handle); @@ -898,6 +993,51 @@ class Pet { this.lastMessage = performance.now(); } + roll() { + this.el.classList.remove('rolling'); + void this.el.offsetWidth; + this.el.classList.add('rolling'); + setTimeout(() => this.el.classList.remove('rolling'), 800); + this.lastMessage = performance.now(); + } + + smoke() { + this.el.classList.remove('smoking'); + void this.el.offsetWidth; + const { svg, durationMs } = randomSmokeSvg(); + this.smokeEl.innerHTML = svg; + this.el.classList.add('smoking'); + setTimeout(() => { + this.el.classList.remove('smoking'); + this.smokeEl.innerHTML = ''; + }, durationMs); + this.lastMessage = performance.now(); + } + + sleep() { + this.setState('sleep'); + this.stateTimer = 0; + this.nextDecision = 12 + Math.random() * 12; + this.lastMessage = performance.now(); + saveStateDebounced(); + } + + teleport() { + this.el.classList.remove('teleporting'); + void this.el.offsetWidth; + this.el.classList.add('teleporting'); + // Move during the invisible middle of the fade + setTimeout(() => { + this.x = Math.random() * (window.innerWidth - 110); + // Sometimes flip direction for variety + if (Math.random() < 0.5) this.dir *= -1; + this.applyTransform(); + }, 320); + setTimeout(() => this.el.classList.remove('teleporting'), 720); + this.lastMessage = performance.now(); + saveStateDebounced(); + } + showMessage(rawText) { if (!rawText) return; const safe = redactBadWords(String(rawText)).slice(0, 100).trim(); @@ -1040,7 +1180,39 @@ async function getProfile(did) { /* ---------- Bang-command scene effects ---------- */ -const fxState = { party: false, snow: false, rain: false, confetti: false, hearts: false, quake: false }; +const fxState = { party: false, snow: false, rain: false, confetti: false, hearts: false, quake: false, fireworks: false }; + +function triggerFireworks() { + if (fxState.fireworks) return; + fxState.fireworks = true; + const overlay = document.createElement('div'); + overlay.className = 'fx-fireworks'; + document.body.appendChild(overlay); + const colors = ['#ff5577', '#ffd93d', '#3affc8', '#ff8e3c', '#c84fff', '#3ad4ff', '#ffffff', '#ff3a8e']; + const burstCount = 7; + for (let b = 0; b < burstCount; b++) { + const cx = 12 + Math.random() * 76; + const cy = 12 + Math.random() * 45; + const color = colors[Math.floor(Math.random() * colors.length)]; + const burstDelay = b * (0.4 + Math.random() * 0.7); + const particleCount = 28 + Math.floor(Math.random() * 18); + for (let i = 0; i < particleCount; i++) { + const p = document.createElement('div'); + p.className = 'firework-particle'; + p.style.left = `${cx}vw`; + p.style.top = `${cy}vh`; + p.style.background = color; + const angle = Math.random() * Math.PI * 2; + const distance = 60 + Math.random() * 140; + p.style.setProperty('--dx', `${(Math.cos(angle) * distance).toFixed(1)}px`); + p.style.setProperty('--dy', `${(Math.sin(angle) * distance + 80).toFixed(1)}px`); + p.style.animationDelay = `${burstDelay.toFixed(2)}s`; + p.style.animationDuration = `${(1.0 + Math.random() * 0.8).toFixed(2)}s`; + overlay.appendChild(p); + } + } + setTimeout(() => { overlay.remove(); fxState.fireworks = false; }, 8500); +} function triggerRain() { if (fxState.rain) return; @@ -1270,6 +1442,10 @@ async function handleChatMessage(authorDid, record) { 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', '💨']), + roll: new Set(['roll', 'rolls', 'barrelroll', 'rolle', 'rollen', 'spin', '🌀']), + teleport: new Set(['teleport', 'tp', 'warp', 'beam', '✨']), + sleep: new Set(['sleep', 'sleeping', 'snooze', 'rest', 'schlafen', 'schlaf', 'slapen', 'slaap', '😴', '💤']), + smoke: new Set(['smoke', 'smoking', 'cig', 'cigarette', 'rauch', 'rauchen', 'rook', 'roken', '🚬']), 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']), @@ -1287,6 +1463,7 @@ async function handleChatMessage(authorDid, record) { if (/!confetti\b/i.test(text)) { triggerConfetti(); bangFired = true; } if (/!hearts?\b/i.test(text)) { triggerHearts(); bangFired = true; } if (/!(quake|earthquake|shake)\b/i.test(text)) { triggerQuake(); bangFired = true; } + if (/!fireworks?\b/i.test(text)) { triggerFireworks(); bangFired = true; } // Tokenize: split on whitespace, strip ASCII edge-punctuation, leave letters/digits/emoji intact. const tokens = text.split(/\s+/).map(t => t.replace(/^[!?.,;:'"()\[\]{}*]+|[!?.,;:'"()\[\]{}*]+$/g, '')); @@ -1333,6 +1510,12 @@ async function handleChatMessage(authorDid, record) { if (counts.fart) { for (let i = 0; i < counts.fart; i++) setTimeout(() => pet.fart(), i * 600); } + if (counts.roll) { + for (let i = 0; i < counts.roll; i++) setTimeout(() => pet.roll(), i * 800); + } + if (counts.teleport) pet.teleport(); + if (counts.sleep) pet.sleep(); + if (counts.smoke) pet.smoke(); 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)); diff --git a/scripts/drain-chat.sh b/scripts/drain-chat.sh new file mode 100755 index 0000000..2b3e76c --- /dev/null +++ b/scripts/drain-chat.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Atomically drain a chat log written by listen-chat.ts. +# Renames the log out of the way, prints its contents, then deletes it. +# usage: drain-chat.sh [chat-file] (default: chat.txt) +set -euo pipefail + +src=${1:-chat.txt} +pending=$src.pending + +if [ -f "$pending" ]; then + cat "$pending" + rm -f "$pending" +fi + +if [ -f "$src" ]; then + mv "$src" "$pending" + cat "$pending" + rm -f "$pending" +fi diff --git a/scripts/listen-chat.ts b/scripts/listen-chat.ts index 4a02b70..4d056d7 100644 --- a/scripts/listen-chat.ts +++ b/scripts/listen-chat.ts @@ -5,13 +5,9 @@ import { resolve } from 'node:path'; const COLLECTION = 'place.stream.chat.message'; const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; -const streamerDid = process.argv[2]; -const outFile = resolve(process.argv[3] ?? 'chat.txt'); - -if (!streamerDid || !streamerDid.startsWith('did:')) { - console.error('usage: tsx scripts/listen-chat.ts [out-file]'); - process.exit(1); -} +const args = process.argv.slice(2); +const streamerDid = args[0]?.startsWith('did:') ? args[0] : undefined; +const outFile = resolve((streamerDid ? args[1] : args[0]) ?? 'chat.txt'); const url = `${JETSTREAM_URL}?wantedCollections=${encodeURIComponent(COLLECTION)}`; @@ -35,7 +31,9 @@ type JetstreamEvent = { }; function connect() { - console.error(`[listen-chat] connecting, filtering streamer=${streamerDid}`); + console.error( + `[listen-chat] connecting, ${streamerDid ? `filtering streamer=${streamerDid}` : 'no streamer filter'}`, + ); const ws = new WebSocket(url); ws.addEventListener('open', () => console.error('[listen-chat] connected')); @@ -50,7 +48,8 @@ function connect() { const c = msg.commit; if (!c || c.operation !== 'create' || c.collection !== COLLECTION) return; const rec = c.record; - if (!rec || rec.streamer !== streamerDid) return; + if (!rec) return; + if (streamerDid && rec.streamer !== streamerDid) return; const line = `${msg.did}: ${rec.text}`; appendFileSync(outFile, line + '\n'); diff --git a/scripts/refresh-obs-source.ts b/scripts/refresh-obs-source.ts new file mode 100755 index 0000000..85c6bf8 --- /dev/null +++ b/scripts/refresh-obs-source.ts @@ -0,0 +1,88 @@ +#!/usr/bin/env tsx +// Refresh an OBS Browser Source via obs-websocket v5. +// usage: refresh-obs-source.ts +// env: OBS_WEBSOCKET_URL (default ws://localhost:4455), OBS_WEBSOCKET_PASSWORD +import { createHash } from 'node:crypto'; + +const sourceName = process.argv[2]; +if (!sourceName) { + console.error('usage: refresh-obs-source.ts '); + process.exit(2); +} + +const url = process.env.OBS_WEBSOCKET_URL ?? 'ws://localhost:4455'; +const password = process.env.OBS_WEBSOCKET_PASSWORD ?? ''; + +function sha256b64(input: string) { + return createHash('sha256').update(input).digest('base64'); +} + +const ws = new WebSocket(url); +const requestId = crypto.randomUUID(); +let identified = false; + +ws.addEventListener('open', () => { + console.error(`[refresh-obs-source] connected to ${url}`); +}); + +ws.addEventListener('message', (ev) => { + const msg = JSON.parse(ev.data as string) as { op: number; d: any }; + + if (msg.op === 0) { + // Hello → Identify + const auth = msg.d.authentication; + const identify: any = { rpcVersion: 1 }; + if (auth) { + if (!password) { + console.error('[refresh-obs-source] server requires auth, set OBS_WEBSOCKET_PASSWORD'); + process.exit(1); + } + identify.authentication = sha256b64(sha256b64(password + auth.salt) + auth.challenge); + } + ws.send(JSON.stringify({ op: 1, d: identify })); + return; + } + + if (msg.op === 2) { + // Identified → send refresh request + identified = true; + ws.send( + JSON.stringify({ + op: 6, + d: { + requestType: 'PressInputPropertiesButton', + requestId, + requestData: { inputName: sourceName, propertyName: 'refreshnocache' }, + }, + }), + ); + return; + } + + if (msg.op === 7 && msg.d.requestId === requestId) { + const ok = msg.d.requestStatus?.result; + if (ok) { + console.error(`[refresh-obs-source] refreshed "${sourceName}"`); + ws.close(); + process.exit(0); + } else { + console.error( + `[refresh-obs-source] failed: ${msg.d.requestStatus?.code} ${msg.d.requestStatus?.comment ?? ''}`, + ); + ws.close(); + process.exit(1); + } + } +}); + +ws.addEventListener('error', (ev) => { + console.error('[refresh-obs-source] error', ev); + process.exit(1); +}); + +ws.addEventListener('close', (ev) => { + if (!identified) { + console.error(`[refresh-obs-source] closed before identify (${ev.code})`); + process.exit(1); + } +});