新主题

This commit is contained in:
2026-08-06 00:41:10 -07:00
parent 7eb9d00a50
commit ac80cd1db4
35 changed files with 1693 additions and 24 deletions
+107 -1
View File
@@ -7,6 +7,7 @@ import { translate, useI18n } from './i18n'
import type { ComponentStream } from './stream'
import { defaultGiftEffectSettings } from './types'
import type { GiftEffectSettings, MeteorTierSettings } from './types'
import { typographyVariables } from './typography'
type Viewer = { uid?: string; name?: string }
type Gift = {
@@ -37,6 +38,9 @@ type VisualEffect = {
type GiftTier = 'normal' | 'high' | 'featured'
export type GiftEffectPreviewMode = GiftTier | 'guard'
/** The moonlit theme deliberately reserves a full-scene ceremony for CNY 50+. */
const MOONLIT_CEREMONY_THRESHOLD = 50_000
function hash(value: string): number {
let result = 2166136261
for (let index = 0; index < value.length; index += 1) {
@@ -245,6 +249,95 @@ function MeteorBurst({
)
}
/**
* The small-gift treatment for the sparse moonlit theme. It is intentionally
* compact and stays out of the centre of a broadcaster's scene: a gift image,
* name and a single water-line appear briefly without adding a panel behind it.
*/
function MoonlitGiftWhisper({ effect }: { effect: VisualEffect }) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
const seed = hash(effect.id)
return (
<article
className="moonlit-gift-whisper"
aria-label={gift.name || translate('common.gift')}
style={
{
top: `${8 + (seed % 72)}%`,
right: `${3 + ((seed >>> 8) % 10)}%`,
['--moonlit-whisper-delay' as string]: `${(seed >>> 16) % 260}ms`,
} as CSSProperties
}
>
<i className="moonlit-whisper-ripple" aria-hidden="true" />
<span className="moonlit-whisper-image">
<GiftImage gift={gift} />
</span>
<div className="moonlit-whisper-copy">
<b>{viewer}</b>
<span>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</span>
</div>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</article>
)
}
/**
* A full-viewport but transparent offering ceremony for CNY 50+ gifts. The
* CSS draws moon, water ripples and falling points of light, keeping the gift
* image itself at the centre of the effect and leaving the OBS scene visible.
*/
function MoonlitGiftCeremony({ effect, tier }: { effect: VisualEffect; tier: GiftTier }) {
const gift = effect.payload.gift ?? {}
const viewer = effect.payload.viewer?.name || translate('common.viewer')
const quantity = effect.payload.quantity || 1
const price = gift.priceCny ?? (gift.totalPrice ?? 0) / 1_000
return (
<section className={`moonlit-gift-ceremony tier-${tier}`} aria-live="polite">
<div className="moonlit-ceremony-moon" aria-hidden="true" />
<div className="moonlit-ceremony-water" aria-hidden="true">
<i />
<i />
<i />
</div>
<div className="moonlit-ceremony-stars" aria-hidden="true">
{Array.from({ length: 14 }, (_, index) => {
const seed = hash(`${effect.id}:ceremony:${index}`)
return (
<i
key={index}
style={
{
left: `${seed % 100}%`,
top: `${(seed >>> 8) % 100}%`,
['--moonlit-delay' as string]: `${-((seed >>> 16) % 2_400)}ms`,
['--moonlit-size' as string]: `${4 + ((seed >>> 24) % 10)}px`,
} as CSSProperties
}
/>
)
})}
</div>
<div className="moonlit-ceremony-copy">
<span>{translate('gift.moonlit.ceremony_label')}</span>
<b>{viewer}</b>
<strong>
{translate('overlay.gift', { gift: gift.name || translate('common.gift'), quantity })}
</strong>
{price > 0 && <small>¥ {price.toFixed(2)}</small>}
</div>
<div className="moonlit-ceremony-image">
<GiftImage gift={gift} />
</div>
</section>
)
}
function GuardCelebration({
effect,
settings,
@@ -336,19 +429,32 @@ export function GiftEffectOverlay({
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')
const moonlit = theme.id === 'moonlit-water'
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)}
style={{
...giftThemeVariables(theme),
...typographyVariables(settings.fontFamily, settings.fontBrightness),
}}
>
<div className="meteor-sky" aria-live="polite">
{remote.effects
.filter(effect => effect.kind === 'gift')
.map(effect => {
const tier = tierFor(effect, settings)
const gift = effect.payload.gift
const totalPrice = gift?.totalPrice ?? Math.round((gift?.priceCny ?? 0) * 1_000)
if (moonlit) {
return totalPrice >= MOONLIT_CEREMONY_THRESHOLD ? (
<MoonlitGiftCeremony effect={effect} tier={tier} key={effect.id} />
) : (
<MoonlitGiftWhisper effect={effect} key={effect.id} />
)
}
return (
<MeteorBurst
effect={effect}