mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-15 14:22:43 +00:00
feat(desktop): TikTok-style vibe hearts on a reusable particle system
Add a glyph-agnostic ParticleField (float-up + organic sway/bank + springy pop-in), skinned as pink pixel hearts. Hearts play on the pet when one is out (in-window or popped out) and celebrate alongside; otherwise they rise from the composer. A generic $petReaction bus mirrors the burst to the pop-out overlay window so it reacts even while the app is minimized. Consume the core `reaction` event to fire hearts on affectionate messages. DEV Shift+H previews a burst.
This commit is contained in:
parent
fbefb5c075
commit
3aaf7e3876
9 changed files with 553 additions and 2 deletions
|
|
@ -12,6 +12,7 @@ import { useLocation } from 'react-router-dom'
|
|||
|
||||
import { Thread } from '@/components/assistant-ui/thread'
|
||||
import { Backdrop } from '@/components/Backdrop'
|
||||
import { COMPOSER_HEART_CONFIG, HeartField } from '@/components/chat/vibe-hearts'
|
||||
import { PromptOverlays } from '@/components/prompt-overlays'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
|
|
@ -30,6 +31,8 @@ import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-s
|
|||
import { cn } from '@/lib/utils'
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
|
|
@ -297,6 +300,10 @@ export function ChatView({
|
|||
const currentCwd = useStore($currentCwd)
|
||||
const currentModel = useStore($currentModel)
|
||||
const currentProvider = useStore($currentProvider)
|
||||
// A pet anywhere (in-window or popped out) owns the hearts; composer only when none.
|
||||
const petActive = useStore($petActive)
|
||||
const petOverlayActive = useStore($petOverlayActive)
|
||||
const petPresent = petActive || petOverlayActive
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const gatewaySwapTarget = useStore($gatewaySwapTarget)
|
||||
|
|
@ -491,6 +498,18 @@ export function ChatView({
|
|||
</div>
|
||||
)}
|
||||
{showChatBar && <ScrollToBottomButton />}
|
||||
{/* Vibe hearts rise from the composer only when no pet is out (else
|
||||
they play on the pet). DEV Shift+H to preview; not wired to ily/<3 yet. */}
|
||||
{!petPresent && (
|
||||
<HeartField
|
||||
className="absolute inset-x-0 z-30"
|
||||
config={COMPOSER_HEART_CONFIG}
|
||||
style={{
|
||||
top: 0,
|
||||
bottom: 'calc(var(--composer-measured-height) + var(--status-stack-measured-height) + 0.25rem)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<ChatDropOverlay kind={dragKind} />
|
||||
<ChatSwapOverlay profile={gatewaySwapTarget} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { PetHeartField, playVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { PetBubble } from '@/components/pet/pet-bubble'
|
||||
import { PetSprite } from '@/components/pet/pet-sprite'
|
||||
import { type PetZoomAnchor, usePetZoomGesture } from '@/components/pet/use-pet-zoom-gesture'
|
||||
|
|
@ -72,6 +73,8 @@ export function PetOverlayApp() {
|
|||
const zoomAnchorRef = useRef<PetZoomAnchor | null>(null)
|
||||
const petRef = useRef<HTMLDivElement | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement | null>(null)
|
||||
// Last mirrored reaction id — a bump means the main window fired a reaction.
|
||||
const lastReactionRef = useRef<number | null>(null)
|
||||
const ignoreRef = useRef(true)
|
||||
const composerOpenRef = useRef(false)
|
||||
const clickTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
|
@ -91,6 +94,19 @@ export function PetOverlayApp() {
|
|||
setBusy(Boolean(payload.busy))
|
||||
setAwaitingResponse(Boolean(payload.awaiting))
|
||||
setUnread(Boolean(payload.unread))
|
||||
|
||||
// Play a reaction on a new id (ignore the first sync, which just primes it).
|
||||
const reaction = payload.reaction ?? null
|
||||
|
||||
if (lastReactionRef.current === null) {
|
||||
lastReactionRef.current = reaction?.id ?? 0
|
||||
} else if (reaction && reaction.id > lastReactionRef.current) {
|
||||
lastReactionRef.current = reaction.id
|
||||
|
||||
if (reaction.kind === 'vibe') {
|
||||
playVibeHearts()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Tell the main renderer we're mounted so it pushes the current frame (the
|
||||
|
|
@ -416,6 +432,12 @@ export function PetOverlayApp() {
|
|||
<div style={{ lineHeight: 0, position: 'relative' }}>
|
||||
<PetSprite info={info} />
|
||||
|
||||
{/* Hearts on the popped-out pet — identical to in-window. */}
|
||||
<PetHeartField
|
||||
petH={(info.frameH ?? DEFAULT_FRAME_H) * (info.scale ?? DEFAULT_SCALE)}
|
||||
petW={(info.frameW ?? DEFAULT_FRAME_W) * (info.scale ?? DEFAULT_SCALE)}
|
||||
/>
|
||||
|
||||
{/* Mail icon: only when a finish landed while you were away. Jumps to
|
||||
the app's most recent thread. Anchored to the sprite (kept inside
|
||||
its box so the overlay's click-through hit-test still catches it);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { type MutableRefObject, useCallback } from 'react'
|
|||
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
|
||||
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
|
||||
import { closeAgentTerminalByProc } from '@/app/right-sidebar/terminal/terminals'
|
||||
import { burstVibeHearts } from '@/components/chat/vibe-hearts'
|
||||
import { translateNow } from '@/i18n'
|
||||
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
|
||||
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
|
||||
|
|
@ -264,6 +265,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
|||
// KawaiiSpinner), not real reasoning. The bottom-of-thread loading
|
||||
// indicator already covers that UX, so we ignore these events to
|
||||
// avoid a duplicative "Thinking" disclosure showing spinner text.
|
||||
} else if (event.type === 'reaction') {
|
||||
// Core-detected affection (ily / <3 / good bot) on the user's message.
|
||||
// Play hearts only for the visible session so background turns stay quiet.
|
||||
if (isActiveEvent && (payload?.kind ?? 'vibe') === 'vibe') {
|
||||
burstVibeHearts()
|
||||
}
|
||||
} else if (event.type === 'reasoning.delta') {
|
||||
if (sessionId) {
|
||||
appendReasoningDelta(sessionId, coerceThinkingText(payload?.text))
|
||||
|
|
|
|||
142
apps/desktop/src/components/chat/vibe-hearts.tsx
Normal file
142
apps/desktop/src/components/chat/vibe-hearts.tsx
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { type CSSProperties, useEffect } from 'react'
|
||||
|
||||
import {
|
||||
createParticleEmitter,
|
||||
ParticleField,
|
||||
type ParticleFieldConfig
|
||||
} from '@/components/particles/particle-field'
|
||||
import { $petActive, flashPetActivity } from '@/store/pet'
|
||||
import { $petOverlayActive, forwardPetReaction } from '@/store/pet-overlay'
|
||||
|
||||
/**
|
||||
* TikTok-style floating hearts — a thin skin over {@link ParticleField} (pixel
|
||||
* heart glyph + pink). Placed two ways: rising from the composer when no pet is
|
||||
* out, or from the pet when one is. Not wired to vibes/`ily`/`<3` yet — call
|
||||
* {@link burstVibeHearts} (or hit the DEV hotkey Shift+H).
|
||||
*/
|
||||
|
||||
// Light pink reads on both light and dark chat surfaces.
|
||||
const HEART_COLORS = ['#ff9ec4'] as const
|
||||
|
||||
/** Composer placement: hearts rise the thread height (rise = % of the tall lane). */
|
||||
export const COMPOSER_HEART_CONFIG: Partial<ParticleFieldConfig> = {
|
||||
count: 12,
|
||||
size: [6, 13],
|
||||
rise: [6.75, 15.75],
|
||||
duration: [320, 700]
|
||||
}
|
||||
|
||||
/** Pet placement: a compact puff off the pet. The field box spans feet→head, so
|
||||
* rise ≥100% carries hearts from the feet to ~10-20% above the pet before fading. */
|
||||
const PET_HEART_CONFIG: Partial<ParticleFieldConfig> = {
|
||||
count: 10,
|
||||
spawnWindowMs: 450,
|
||||
size: [6, 12],
|
||||
rise: [98, 118],
|
||||
duration: [480, 880],
|
||||
swayAmp: [5, 14],
|
||||
bank: [6, 14]
|
||||
}
|
||||
|
||||
// Pixel-art heart from @nous-research/ui (14×12), crisp + `currentColor`.
|
||||
const HEART_GLYPH = (
|
||||
<svg fill="none" shapeRendering="crispEdges" viewBox="0 0 14 12" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.2 0v5.65714h-1.8857v1.88572H9.42857v1.88571H7.54286v1.88573H5.65714V9.42857H3.77143V7.54286H1.88571V5.65714H0V0h5.65714v1.88571h1.88572V0z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
const emitter = createParticleEmitter()
|
||||
|
||||
/** Play hearts in THIS window (whichever HeartField is mounted). The overlay
|
||||
* window calls this directly off the mirrored vibe signal. */
|
||||
export const playVibeHearts = (count?: number) => emitter.burst(count)
|
||||
|
||||
/**
|
||||
* Fire a vibe burst from anywhere (hotkey, future `ily`/`<3` wiring). Routes to
|
||||
* where the affection should land:
|
||||
* - pet popped out → forward to the overlay window + celebrate (mirrored)
|
||||
* - pet in-window → play here (on the pet) + celebrate
|
||||
* - no pet → play here (composer)
|
||||
*/
|
||||
export const burstVibeHearts = (count?: number) => {
|
||||
const overlay = $petOverlayActive.get()
|
||||
|
||||
if (overlay || $petActive.get()) {
|
||||
flashPetActivity({ celebrate: true })
|
||||
}
|
||||
|
||||
if (overlay) {
|
||||
forwardPetReaction('vibe')
|
||||
} else {
|
||||
playVibeHearts(count)
|
||||
}
|
||||
}
|
||||
|
||||
/** DEV-only preview: Shift+H, firing even while the composer is focused. Mount
|
||||
* once in an always-present component (FloatingPet) — a single listener. */
|
||||
export function useHeartPreviewHotkey() {
|
||||
useEffect(() => {
|
||||
if (!import.meta.env.DEV) {
|
||||
return
|
||||
}
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent) => {
|
||||
if (!e.shiftKey || e.repeat || e.altKey || e.ctrlKey || e.metaKey || e.code !== 'KeyH') {
|
||||
return
|
||||
}
|
||||
|
||||
e.preventDefault()
|
||||
burstVibeHearts()
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', onKeyDown)
|
||||
|
||||
return () => window.removeEventListener('keydown', onKeyDown)
|
||||
}, [])
|
||||
}
|
||||
|
||||
export interface HeartFieldProps {
|
||||
config?: Partial<ParticleFieldConfig>
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
/** Heart-skinned particle field. Caller supplies placement + a config preset. */
|
||||
export function HeartField({ config, className, style }: HeartFieldProps) {
|
||||
return (
|
||||
<ParticleField
|
||||
className={className}
|
||||
colors={HEART_COLORS}
|
||||
config={config}
|
||||
emitter={emitter}
|
||||
glyph={HEART_GLYPH}
|
||||
style={style}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Pet-anchored hearts, feet→~10-20% above. One place owns the geometry so the
|
||||
* in-window pet and the popped-out overlay stay identical. `petW`/`petH` are the
|
||||
* rendered sprite dimensions (frame × scale).
|
||||
*/
|
||||
export function PetHeartField({ petW, petH }: { petW: number; petH: number }) {
|
||||
return (
|
||||
<HeartField
|
||||
config={PET_HEART_CONFIG}
|
||||
style={{
|
||||
bottom: 0,
|
||||
height: Math.max(96, petH),
|
||||
left: '50%',
|
||||
pointerEvents: 'none',
|
||||
position: 'absolute',
|
||||
transform: 'translateX(-50%)',
|
||||
width: Math.max(90, petW * 1.5),
|
||||
zIndex: 2
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
126
apps/desktop/src/components/particles/particle-field.css
Normal file
126
apps/desktop/src/components/particles/particle-field.css
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/* ── Particle field ─────────────────────────────────────────────────────────
|
||||
Reusable float-up emitter. Three nested layers, each owning ONE transform so
|
||||
they never fight:
|
||||
.particle — full-height track: vertical rise + fade (linear)
|
||||
.particle__sway — horizontal weave + bank/tilt on its OWN period
|
||||
(ease-in-out, alternating), so the path desyncs from
|
||||
the rise and never repeats — the organic-motion trick
|
||||
.particle__glyph — one-shot springy pop-in scale
|
||||
The track is full-height and anchored at the field bottom, so the rise's
|
||||
`translateY(-rise%)` measures against the field height, not the glyph's. All
|
||||
tuning arrives as inline `--particle-*` custom properties. */
|
||||
.particle-field {
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
left: var(--particle-left);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
width: var(--particle-size);
|
||||
margin-left: calc(var(--particle-size) / -2);
|
||||
will-change: transform, opacity;
|
||||
animation: particle-rise var(--particle-duration) linear var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__sway {
|
||||
display: block;
|
||||
will-change: transform;
|
||||
animation: particle-sway var(--particle-sway-duration) ease-in-out var(--particle-sway-delay)
|
||||
infinite alternate;
|
||||
}
|
||||
|
||||
.particle__glyph {
|
||||
display: block;
|
||||
width: var(--particle-size);
|
||||
color: var(--particle-color);
|
||||
will-change: transform;
|
||||
animation: particle-pop 260ms cubic-bezier(0.34, 1.56, 0.64, 1) var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__glyph > svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* Rise: straight up the field, holds opacity through most of the climb, fades
|
||||
near the top. */
|
||||
@keyframes particle-rise {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
|
||||
6% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -0.06%), 0);
|
||||
}
|
||||
|
||||
70% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -0.7%), 0);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, calc(var(--particle-rise) * -1%), 0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Sway + bank: swings left↔right and tilts INTO the direction of travel, like a
|
||||
leaf on a breeze. Runs on its own clock. */
|
||||
@keyframes particle-sway {
|
||||
from {
|
||||
transform: translateX(calc(var(--particle-sway) * -1)) rotate(calc(var(--particle-bank) * -1));
|
||||
}
|
||||
|
||||
to {
|
||||
transform: translateX(var(--particle-sway)) rotate(var(--particle-bank));
|
||||
}
|
||||
}
|
||||
|
||||
/* Pop: a springy scale-in overshoot (the bezier is a spring, not a path wobble). */
|
||||
@keyframes particle-pop {
|
||||
0% {
|
||||
transform: scale(0.3);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.particle {
|
||||
inset-block: auto 0;
|
||||
height: var(--particle-size);
|
||||
animation: particle-flash 650ms ease-out var(--particle-delay) both;
|
||||
}
|
||||
|
||||
.particle__sway,
|
||||
.particle__glyph {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
@keyframes particle-flash {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 0, 0) scale(0.6);
|
||||
}
|
||||
|
||||
20% {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, -0.5rem, 0) scale(1.1);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, -1rem, 0) scale(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
204
apps/desktop/src/components/particles/particle-field.tsx
Normal file
204
apps/desktop/src/components/particles/particle-field.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import './particle-field.css'
|
||||
|
||||
import { type CSSProperties, type ReactNode, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
/**
|
||||
* Reusable float-up particle emitter. It owns the motion (rise + organic sway +
|
||||
* springy pop) and lifecycle (staggered burst, lifetime, cleanup); callers just
|
||||
* hand it a `glyph` (any element using `currentColor`) and `colors`, then place
|
||||
* it with `className` / `style`. See {@link VibeHearts} for the chat-hearts use.
|
||||
*/
|
||||
|
||||
type Range = readonly [min: number, max: number]
|
||||
|
||||
const rand = ([min, max]: Range) => min + Math.random() * (max - min)
|
||||
/** Sample a range along `t` (0→min, 1→max) — couples travel/lifetime to `life`. */
|
||||
const lerp = ([min, max]: Range, t: number) => min + (max - min) * t
|
||||
|
||||
export interface ParticleFieldConfig {
|
||||
/** Particles per burst when `burst()` is called without a count. */
|
||||
count: number
|
||||
/** Window (ms) over which a burst releases its particles — small = one poof. */
|
||||
spawnWindowMs: number
|
||||
/** Glyph edge size (px), uniform. */
|
||||
size: Range
|
||||
/** Vertical travel before fade-out (% of field height), `life`-biased. */
|
||||
rise: Range
|
||||
/** Rise duration (ms), `life`-biased so short-lived particles also rise less. */
|
||||
duration: Range
|
||||
/** Sway amplitude each side of center (px), uniform. */
|
||||
swayAmp: Range
|
||||
/** Peak tilt into the sway (deg), uniform. */
|
||||
bank: Range
|
||||
/** Sway period (ms), uniform — independent of the rise so paths never repeat. */
|
||||
swayDuration: Range
|
||||
/** Cap on simultaneously-alive particles. */
|
||||
maxAlive: number
|
||||
}
|
||||
|
||||
export const DEFAULT_PARTICLE_CONFIG: ParticleFieldConfig = {
|
||||
count: 12,
|
||||
spawnWindowMs: 550,
|
||||
size: [6, 13],
|
||||
rise: [6.75, 15.75],
|
||||
duration: [320, 700],
|
||||
swayAmp: [9, 24],
|
||||
bank: [7, 16],
|
||||
swayDuration: [1300, 2800],
|
||||
maxAlive: 200
|
||||
}
|
||||
|
||||
export interface ParticleEmitter {
|
||||
/** Fire a burst (defaults to the field's configured `count`). */
|
||||
burst: (count?: number) => void
|
||||
/** Internal: field subscription. */
|
||||
subscribe: (fn: (count?: number) => void) => () => void
|
||||
}
|
||||
|
||||
/** Create an emitter handle. `burst()` is safe to call from anywhere. */
|
||||
export function createParticleEmitter(): ParticleEmitter {
|
||||
const listeners = new Set<(count?: number) => void>()
|
||||
|
||||
return {
|
||||
burst: count => listeners.forEach(fn => fn(count)),
|
||||
subscribe: fn => {
|
||||
listeners.add(fn)
|
||||
|
||||
return () => void listeners.delete(fn)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface Particle {
|
||||
id: number
|
||||
leftPct: number
|
||||
size: number
|
||||
color: string
|
||||
delayMs: number
|
||||
durationMs: number
|
||||
rise: number
|
||||
swayAmp: number
|
||||
bank: number
|
||||
swayDurationMs: number
|
||||
swayDelayMs: number
|
||||
}
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function spawn(cfg: ParticleFieldConfig, colors: readonly string[]): Particle {
|
||||
// Short-lived particles fade out lower; a few live longer and rise higher.
|
||||
const life = Math.random() ** 1.7
|
||||
const swayDurationMs = Math.round(rand(cfg.swayDuration))
|
||||
|
||||
return {
|
||||
id: nextId++,
|
||||
// Spread edge to edge across the lane, not clustered near center.
|
||||
leftPct: 4 + Math.random() * 92,
|
||||
size: rand(cfg.size),
|
||||
color: colors[Math.floor(Math.random() * colors.length)]!,
|
||||
delayMs: Math.round(Math.random() * 120),
|
||||
durationMs: Math.round(lerp(cfg.duration, life)),
|
||||
rise: lerp(cfg.rise, life),
|
||||
swayAmp: rand(cfg.swayAmp),
|
||||
bank: rand(cfg.bank),
|
||||
swayDurationMs,
|
||||
// Negative delay drops each particle in mid-swing (desynced phases).
|
||||
swayDelayMs: -Math.round(Math.random() * swayDurationMs)
|
||||
}
|
||||
}
|
||||
|
||||
const prefersReducedMotion = () =>
|
||||
typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
|
||||
export interface ParticleFieldProps {
|
||||
emitter: ParticleEmitter
|
||||
/** Any element that paints with `currentColor` (SVG, glyph, …). */
|
||||
glyph: ReactNode
|
||||
colors: readonly string[]
|
||||
config?: Partial<ParticleFieldConfig>
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
}
|
||||
|
||||
export function ParticleField({ emitter, glyph, colors, config, className, style }: ParticleFieldProps) {
|
||||
const cfg = useMemo(() => ({ ...DEFAULT_PARTICLE_CONFIG, ...config }), [config])
|
||||
const [particles, setParticles] = useState<Particle[]>([])
|
||||
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
|
||||
|
||||
useEffect(() => {
|
||||
const pool = timers.current
|
||||
const add = () => setParticles(prev => [...prev, spawn(cfg, colors)].slice(-cfg.maxAlive))
|
||||
|
||||
// Release a burst across a tight window so it reads as one poof, each node
|
||||
// with its own random birth time; reduced motion gets a single flash.
|
||||
const onBurst = (count?: number) => {
|
||||
const n = Math.max(1, Math.min(cfg.maxAlive, Math.round(count ?? cfg.count)))
|
||||
|
||||
if (prefersReducedMotion()) {
|
||||
add()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < n; i++) {
|
||||
const timer = setTimeout(() => {
|
||||
pool.delete(timer)
|
||||
add()
|
||||
}, Math.random() * cfg.spawnWindowMs)
|
||||
|
||||
pool.add(timer)
|
||||
}
|
||||
}
|
||||
|
||||
const unsubscribe = emitter.subscribe(onBurst)
|
||||
|
||||
return () => {
|
||||
unsubscribe()
|
||||
pool.forEach(clearTimeout)
|
||||
pool.clear()
|
||||
}
|
||||
}, [cfg, colors, emitter])
|
||||
|
||||
const remove = (id: number) => setParticles(prev => prev.filter(p => p.id !== id))
|
||||
|
||||
if (particles.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div aria-hidden className={cn('particle-field', className)} style={style}>
|
||||
{particles.map(p => (
|
||||
<span
|
||||
className="particle"
|
||||
key={p.id}
|
||||
// Retire on the RISE track only (sway is infinite, pop is shorter).
|
||||
onAnimationEnd={e => {
|
||||
if (e.animationName === 'particle-rise' || e.animationName === 'particle-flash') {
|
||||
remove(p.id)
|
||||
}
|
||||
}}
|
||||
style={
|
||||
{
|
||||
'--particle-left': `${p.leftPct}%`,
|
||||
'--particle-size': `${p.size}px`,
|
||||
'--particle-color': p.color,
|
||||
'--particle-delay': `${p.delayMs}ms`,
|
||||
'--particle-duration': `${p.durationMs}ms`,
|
||||
'--particle-rise': p.rise,
|
||||
'--particle-sway': `${p.swayAmp}px`,
|
||||
'--particle-bank': `${p.bank}deg`,
|
||||
'--particle-sway-duration': `${p.swayDurationMs}ms`,
|
||||
'--particle-sway-delay': `${p.swayDelayMs}ms`
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<span className="particle__sway">
|
||||
<span className="particle__glyph">{glyph}</span>
|
||||
</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'
|
|||
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
|
||||
import { useOnProfileSwitch } from '@/app/hooks/use-on-profile-switch'
|
||||
import { useRouteOverlayActive } from '@/app/hooks/use-route-overlay-active'
|
||||
import { PetHeartField, useHeartPreviewHotkey } from '@/components/chat/vibe-hearts'
|
||||
import { persistString, storedString } from '@/lib/storage'
|
||||
import {
|
||||
$petAtRest,
|
||||
|
|
@ -120,6 +121,10 @@ export function FloatingPet() {
|
|||
const roamDir = useStore($petRoamDir)
|
||||
const routeOverlayOpen = useRouteOverlayActive()
|
||||
|
||||
// DEV Shift+H heart preview lives here — FloatingPet is always mounted
|
||||
// (app-shell), so one listener covers every route whether a pet is out or not.
|
||||
useHeartPreviewHotkey()
|
||||
|
||||
const [position, setPosition] = useState<Point>(loadPosition)
|
||||
const containerRef = useRef<HTMLDivElement | null>(null)
|
||||
// The facing mirror lives on the sprite wrapper, not the container, so the
|
||||
|
|
@ -447,6 +452,9 @@ export function FloatingPet() {
|
|||
>
|
||||
<PetSprite info={info} rowOverride={walk.row} />
|
||||
</div>
|
||||
{/* Hearts puff off the pet; its celebrate ("yay"/jump) pose is driven by
|
||||
burstVibeHearts's router. */}
|
||||
<PetHeartField petH={petH} petW={petW} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,6 +46,8 @@ export interface PetOverlayStatePayload {
|
|||
awaiting: boolean
|
||||
/** Drives the overlay's mail icon: a finish landed while you were away. */
|
||||
unread: boolean
|
||||
/** Latest reaction — bumping its id forwards a burst to the overlay. */
|
||||
reaction: PetReaction | null
|
||||
}
|
||||
|
||||
export type PetOverlayControl =
|
||||
|
|
@ -67,6 +69,22 @@ export const $petOverlayActive = atom(storedBoolean(OVERLAY_ACTIVE_KEY, false))
|
|||
// Persist the in/out choice so a popped-out pet comes back popped out.
|
||||
$petOverlayActive.subscribe(active => persistBoolean(OVERLAY_ACTIVE_KEY, active))
|
||||
|
||||
/**
|
||||
* Reaction signal forwarded to the popped-out overlay window via the state
|
||||
* mirror below. `id` is a monotonic nonce so the overlay fires once per bump;
|
||||
* `kind` selects the renderer (today only `vibe` → hearts). Generic on purpose
|
||||
* so future reactions (emoji, etc.) ride the same channel.
|
||||
*/
|
||||
export interface PetReaction {
|
||||
id: number
|
||||
kind: string
|
||||
}
|
||||
|
||||
export const $petReaction = atom<PetReaction | null>(null)
|
||||
|
||||
export const forwardPetReaction = (kind: string) =>
|
||||
$petReaction.set({ id: ($petReaction.get()?.id ?? 0) + 1, kind })
|
||||
|
||||
function loadSavedBounds(): null | PetOverlayBounds {
|
||||
try {
|
||||
const raw = storedString(OVERLAY_BOUNDS_KEY)
|
||||
|
|
@ -129,7 +147,8 @@ function currentPayload(): PetOverlayStatePayload {
|
|||
activity: $petActivity.get(),
|
||||
busy: $busy.get(),
|
||||
awaiting: $awaitingResponse.get(),
|
||||
unread: $petUnread.get()
|
||||
unread: $petUnread.get(),
|
||||
reaction: $petReaction.get()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +184,8 @@ function openOverlay(request: PetOverlayOpenRequest): void {
|
|||
$petActivity.subscribe(pushNow),
|
||||
$busy.subscribe(pushNow),
|
||||
$awaitingResponse.subscribe(pushNow),
|
||||
$petUnread.subscribe(pushNow)
|
||||
$petUnread.subscribe(pushNow),
|
||||
$petReaction.subscribe(pushNow)
|
||||
]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -92,6 +92,9 @@ export function derivePetState(activity: PetActivity): PetState {
|
|||
export const $petInfo = atom<PetInfo>({ enabled: false })
|
||||
export const $petActivity = atom<PetActivity>({})
|
||||
|
||||
/** Pet installed + enabled with a loaded spritesheet (ready to show/react). */
|
||||
export const $petActive = computed($petInfo, info => info.enabled && Boolean(info.spritesheetBase64))
|
||||
|
||||
/**
|
||||
* Profile the pet RPCs should resolve against. Pets are per-profile — the active
|
||||
* pet (`display.pet.*`) and the installed sprites live under each profile's
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue