diff --git a/pets.html b/pets.html index 27be8c0..816a380 100644 --- a/pets.html +++ b/pets.html @@ -350,19 +350,9 @@ 100% { transform: rotate(360deg); } } - /* "blur" / "unblur" — chat-triggered scene-wide blur. CSS blur is - cheaper than a real downscale-and-upscale pixelation pass and reads - fine on stream. Covers the stage and every fx-* overlay; leaves - status/version/toast labels untouched. */ - body.fx-blur #stage, - body.fx-blur .fx-rain, - body.fx-blur .fx-snow, - body.fx-blur .fx-confetti, - body.fx-blur .fx-hearts, - body.fx-blur .fx-party { - filter: blur(8px) contrast(1.2) saturate(1.2); - transition: filter 0.4s ease; - } + /* Per-pet blur — each `blur` chat token adds a step (≈2px), `unblur` + clears. Combined with the bulge filter via the JS visual-filter helper. */ + .pet .pet-flip { transition: filter 0.25s ease; } /* !quake — shakes the stage */ .fx-quake { animation: quake 0.55s linear; } @@ -872,6 +862,7 @@ class Pet { 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); @@ -984,6 +975,7 @@ class Pet { this.stretchY = 1; this.el.style.setProperty('--anim-speed', 1); this.setBulgeLevel(0); + this.setBlurLevel(0); this.lastMessage = performance.now(); this.applyTransform(); saveStateDebounced(); @@ -997,45 +989,61 @@ class Pet { const defs = document.querySelector('#filter-defs defs'); let filter = document.getElementById(filterId); if (this.bulgeLevel <= 0) { - this.flipEl.style.filter = ''; if (filter) filter.remove(); - this.lastMessage = performance.now(); - saveStateDebounced(); - return; - } - 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); + } 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)); } - // Each level = a noticeable but bounded distortion. - const disp = filter.querySelector('feDisplacementMap'); - disp.setAttribute('scale', String(this.bulgeLevel * 14)); - this.flipEl.style.filter = `url(#${filterId})`; + 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) { @@ -1513,6 +1521,7 @@ function saveState() { 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, @@ -1548,6 +1557,7 @@ function restoreState() { 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); @@ -1661,6 +1671,8 @@ async function handleChatMessage(authorDid, record) { 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 @@ -1691,6 +1703,7 @@ async function handleChatMessage(authorDid, record) { 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; @@ -1701,6 +1714,7 @@ async function handleChatMessage(authorDid, record) { 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); saveStateDebounced(); @@ -1764,8 +1778,6 @@ 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 (/\bblur\b/i.test(text)) { document.body.classList.add('fx-blur'); bangFired = true; } - if (/\bunblur\b/i.test(text)) { document.body.classList.remove('fx-blur'); bangFired = true; } if (/\bbarrel\s*roll\b/i.test(text)) { triggerBarrelRoll(authorDid); bangFired = true; } // Tokenize: split on whitespace, strip ASCII edge-punctuation, leave letters/digits/emoji intact. @@ -1819,6 +1831,8 @@ async function handleChatMessage(authorDid, record) { 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('💨'))) { @@ -1844,6 +1858,7 @@ const LIMITS = SAFE_LIMITS ? { stretchX: [0.4, 3.5], stretchY: [0.4, 3.5], bulgeLevel: [0, 8], + blurLevel: [0, 6], xN: 10, jumpQueue: 5, fartQueue: 5, @@ -1853,6 +1868,7 @@ const LIMITS = SAFE_LIMITS ? { stretchX: [0.005, 80], stretchY: [0.005, 80], bulgeLevel: [0, 30], + blurLevel: [0, 20], xN: 100, jumpQueue: 50, fartQueue: 50,