add full-screen gift effects

This commit is contained in:
2026-07-19 00:39:56 -07:00
parent f79852d8e6
commit c4eca7b8bf
23 changed files with 1666 additions and 33 deletions
+369
View File
@@ -0,0 +1,369 @@
/** Full-viewport gift meteor and guard celebration renderer. */
import { useEffect, useRef, useState } from 'react'
import type { CSSProperties } from 'react'
import { normalizeGiftEffectSettings } from './api'
import { getGiftEffectTheme, giftThemeVariables } from './giftThemes'
import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftEffectSettings } from './types'
import type { GiftEffectSettings, MeteorTierSettings } from './types'
type Viewer = { uid?: string; name?: string }
type Gift = {
name?: string
totalPrice?: number
priceCny?: number
imageUrl?: string
animationUrl?: string
}
type EffectPayload = {
viewer?: Viewer
gift?: Gift
quantity?: number
guardName?: string
price?: number
settings?: Partial<GiftEffectSettings>
}
type EffectEnvelope = { id: string; type: string; payload?: EffectPayload }
type VisualEffect = {
id: string
kind: 'gift' | 'guard'
payload: EffectPayload
receivedAt: number
expiresAt: number
/** Preview cards force a tier so custom thresholds do not change the selected demo. */
previewTier?: GiftTier
}
type GiftTier = 'normal' | 'high' | 'featured'
export type GiftEffectPreviewMode = GiftTier | 'guard'
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
result ^= value.charCodeAt(index)
result = Math.imul(result, 16777619)
}
return result >>> 0
}
function tierFor(effect: VisualEffect, settings: GiftEffectSettings): GiftTier {
if (effect.previewTier) return effect.previewTier
const value = effect.payload.gift?.totalPrice ?? 0
if (value >= settings.featuredValueThreshold) return 'featured'
if (value >= settings.highValueThreshold) return 'high'
return 'normal'
}
function previewEnvelope(mode: GiftEffectPreviewMode, nonce: number): EffectEnvelope {
const viewer = { uid: 'preview', name: translate('gift.preview.viewer') }
if (mode === 'guard') {
return {
id: `preview-guard-${nonce}`,
type: 'live.guard.buy',
payload: { viewer, guardName: translate('gift.preview.guard'), quantity: 1, price: 198_000 },
}
}
const totalPrice = mode === 'featured' ? 300_000 : mode === 'high' ? 30_000 : 1_000
return {
id: `preview-${mode}-${nonce}`,
type: 'live.gift',
payload: {
viewer,
quantity: 1,
gift: {
name: translate(`gift.preview.${mode}`),
totalPrice,
priceCny: totalPrice / 1000,
imageUrl: '/pwa/icon-192.png',
},
},
}
}
function toVisualEffect(
envelope: EffectEnvelope,
guardDuration: number,
previewTier?: GiftTier,
): VisualEffect | undefined {
const kind =
envelope.type === 'live.gift'
? 'gift'
: envelope.type === 'live.guard.buy'
? 'guard'
: undefined
if (!kind) return undefined
const now = Date.now()
return {
id: envelope.id,
kind,
payload: envelope.payload ?? {},
receivedAt: now,
expiresAt: now + (kind === 'guard' ? guardDuration + 1_500 : 45_000),
previewTier,
}
}
function useGiftEffects(
preview: boolean,
stream: ComponentStream | undefined,
previewSettings: GiftEffectSettings | undefined,
previewMode: GiftEffectPreviewMode,
previewNonce: number,
language: string,
) {
const [settings, setSettings] = useState(defaultGiftEffectSettings)
const [effects, setEffects] = useState<VisualEffect[]>([])
const settingsRef = useRef(settings)
const lastSequenceRef = useRef(0)
useEffect(() => {
settingsRef.current = settings
}, [settings])
useEffect(() => {
if (preview || !stream) return
const pending = stream.messages.filter(message => message.sequence > lastSequenceRef.current)
for (const message of pending) {
lastSequenceRef.current = message.sequence
const envelope = message.envelope as EffectEnvelope
if (
envelope.type === 'component.settings.snapshot' ||
envelope.type === 'component.settings.updated'
) {
const next = normalizeGiftEffectSettings(envelope.payload?.settings)
settingsRef.current = next
setSettings(next)
continue
}
const effect = toVisualEffect(envelope, settingsRef.current.guardEffectDurationMs)
if (!effect) continue
setEffects(current =>
[effect, ...current.filter(item => item.id !== effect.id)].slice(
0,
settingsRef.current.maxConcurrentEffects,
),
)
}
}, [preview, stream, stream?.messages])
useEffect(() => {
if (!preview) return
const effect = toVisualEffect(
previewEnvelope(previewMode, previewNonce),
previewSettings?.guardEffectDurationMs ?? settingsRef.current.guardEffectDurationMs,
previewMode === 'guard' ? undefined : previewMode,
)
if (effect) setEffects(current => [effect, ...current].slice(0, 4))
}, [language, preview, previewMode, previewNonce, previewSettings?.guardEffectDurationMs])
useEffect(() => {
const timer = window.setInterval(() => {
const now = Date.now()
setEffects(current => current.filter(effect => effect.expiresAt > now))
}, 1_000)
return () => window.clearInterval(timer)
}, [])
return { settings, effects }
}
function GiftImage({ gift }: { gift: Gift }) {
const [source, setSource] = useState(gift.animationUrl || gift.imageUrl || '')
useEffect(
() => setSource(gift.animationUrl || gift.imageUrl || ''),
[gift.animationUrl, gift.imageUrl],
)
if (!source) return <span className="meteor-fallback">✦</span>
return (
<img
src={source}
alt={gift.name || translate('common.gift')}
decoding="async"
referrerPolicy="no-referrer"
onError={() => {
if (gift.imageUrl && source !== gift.imageUrl) setSource(gift.imageUrl)
else setSource('')
}}
/>
)
}
function MeteorBurst({
effect,
tier,
tierSettings,
scale,
viewportWidth,
trailIntensity,
lowPerformance,
}: {
effect: VisualEffect
tier: GiftTier
tierSettings: MeteorTierSettings
scale: number
viewportWidth: number
trailIntensity: number
lowPerformance: boolean
}) {
const gift = effect.payload.gift ?? {}
const count = lowPerformance ? Math.min(4, tierSettings.count) : tierSettings.count
const size = Math.max(20, tierSettings.size * scale)
const speed = Math.max(80, tierSettings.speed * scale)
return (
<div className={`meteor-burst tier-${tier}`} aria-label={gift.name || translate('common.gift')}>
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:${index}`)
const startY = 7 + (seed % 78)
const drift = ((seed >>> 8) % 37) - 18
const delay = index * 95 + ((seed >>> 16) % 260)
const duration = Math.max(1_600, ((viewportWidth + size * 7) / speed) * 1_000)
return (
<div
className="gift-meteor"
key={`${effect.id}:${index}`}
style={
{
['--meteor-size' as string]: `${size}px`,
['--meteor-y' as string]: `${startY}%`,
['--meteor-drift' as string]: `${drift}vh`,
['--meteor-delay' as string]: `${delay}ms`,
['--meteor-duration' as string]: `${duration}ms`,
['--trail-opacity' as string]: `${trailIntensity / 100}`,
} as CSSProperties
}
>
<i className="meteor-tail" />
<i className="meteor-spark meteor-spark-a" />
<i className="meteor-spark meteor-spark-b" />
<span className="meteor-core">
<GiftImage gift={gift} />
</span>
</div>
)
})}
</div>
)
}
function GuardCelebration({
effect,
settings,
}: {
effect: VisualEffect
settings: GiftEffectSettings
}) {
const count = settings.lowPerformanceMode
? Math.min(24, settings.guardStarCount)
: settings.guardStarCount
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const guard = effect.payload.guardName || translate('common.guard')
return (
<section
className="guard-celebration"
aria-label={translate('gift.guard_aria')}
style={
{ ['--guard-duration' as string]: `${settings.guardEffectDurationMs}ms` } as CSSProperties
}
>
<div className="guard-nebula" />
<div className="guard-stars" aria-hidden="true">
{Array.from({ length: count }, (_, index) => {
const seed = hash(`${effect.id}:guard:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--star-delay' as string]: `${-((seed >>> 16) % 2_800)}ms`,
['--star-size' as string]: `${4 + ((seed >>> 24) % 13)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="guard-halo" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="guard-copy">
<span>{translate('gift.guard_salute')}</span>
<strong>{translate('gift.guard_title', { guard })}</strong>
<b>{translate('gift.guard_viewer', { viewer })}</b>
</div>
</section>
)
}
export function GiftEffectOverlay({
preview = false,
previewSettings,
previewMode = 'high',
previewNonce = 0,
stream,
}: {
preview?: boolean
previewSettings?: GiftEffectSettings
previewMode?: GiftEffectPreviewMode
previewNonce?: number
stream?: ComponentStream
}) {
const { language } = useI18n()
const remote = useGiftEffects(
preview,
stream,
previewSettings,
previewMode,
previewNonce,
language,
)
const settings = previewSettings ?? remote.settings
const theme = getGiftEffectTheme(settings.themeId)
const root = useRef<HTMLElement>(null)
const [bounds, setBounds] = useState({ width: 1920, height: 1080 })
useEffect(() => {
if (!root.current) return
const observer = new ResizeObserver(([entry]) => {
setBounds({ width: entry.contentRect.width, height: entry.contentRect.height })
})
observer.observe(root.current)
return () => observer.disconnect()
}, [])
const scale = Math.min(1.5, Math.max(0.35, Math.min(bounds.width / 1920, bounds.height / 1080)))
const guard = remote.effects.find(effect => effect.kind === 'guard')
return (
<main
ref={root}
className={`gift-effect-overlay ${theme.className} ${settings.lowPerformanceMode ? 'gift-low-motion' : ''}`}
data-theme={theme.id}
data-connection={stream?.connection || 'idle'}
style={giftThemeVariables(theme)}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
return (
<MeteorBurst
effect={effect}
tier={tier}
tierSettings={settings[tier]}
scale={scale}
viewportWidth={bounds.width}
trailIntensity={settings.trailIntensity}
lowPerformance={settings.lowPerformanceMode}
key={effect.id}
/>
)
})}
</div>
{guard && <GuardCelebration effect={guard} settings={settings} />}
</main>
)
}