diff --git a/README.md b/README.md index 15379a0..f69b131 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # 洛星瓷直播组件服务 -这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬与 `song_request` -点歌姬,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分,后续可以继续增加礼物展示等组件。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 +这是一个多用户 Rust/Axum 直播组件后端。目前内建 `danmaku_overlay` 弹幕姬、`song_request` 点歌姬与 +`gift_effect` +全屏礼物特效,后端已经按“直播源 → 强类型事件 → 组件实例”的方式拆分。镜像构建阶段会编译 React 前端,运行时由 Rust 同域托管控制台和 OBS 页面。 直播连接使用相邻目录中的 [`libilibili`](https://github.com/feliscafra/libilibili) crate。它负责 Cookie/WBI @@ -19,6 +20,7 @@ adapter 只负责把强类型 Bilibili 命令转换成稳定的领域事件。cr - [组件开发指南](docs/components/README.md) - [`danmaku_overlay` 弹幕姬](docs/components/danmaku-overlay.md) - [`song_request` 点歌姬](docs/components/song-request.md) +- [`gift_effect` 全屏礼物特效](docs/components/gift-effect.md) - [WebSocket 实时协议](docs/protocol.md) - [租户、Secret 与部署安全](docs/security.md) - [完整配置注释](config.toml.example) @@ -208,9 +210,12 @@ fragment,不会随最初的 HTTP 请求发送到 Nginx;OBS 页面随后通 `/api/v1/components//stream` 完成认证。令牌只带 `events:subscribe` 权限,不能调用管理或写入接口;轮换后旧令牌立即失效。 -每个账户会自动拥有一个不可删除的点歌姬。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5` +每个账户会自动拥有不可删除的点歌姬和全屏礼物特效组件。观众发送 `点歌 歌名` 加入队列,发送 `打分 1-5` 为当前歌曲评分;主播可从组件设置打开独立统计窗口,置顶、完成或取消队列项。点歌状态和评分持久化在 PostgreSQL,即使 OBS 未连接也不会丢失。 +礼物特效组件在透明全屏浏览器源中展示从左向右飞行的礼物流星,并按礼物原始价值选择数量、尺寸和速度;舰长、提督和总督事件会临时覆盖一层不透明星河庆祝画面。所有档位参数、拖尾、星数、持续时间和低性能模式均可在控制台调整。该组件只订阅一次性 +`live.gift` 与 `live.guard.buy`,不会把连击更新重复播放为新礼物。 + 旧格式 `/obs?token=...` 与 `/ws?token=...` 已移除,避免 bearer token 进入 Nginx 访问日志。升级后请在控制台为组件轮换令牌,并把旧 OBS 源替换为上述新地址;旧令牌一旦轮换便立即失效。 diff --git a/apps/overlay/README.md b/apps/overlay/README.md index 12fd947..56534c6 100644 --- a/apps/overlay/README.md +++ b/apps/overlay/README.md @@ -26,6 +26,8 @@ Vite 在 Docker build stage 编译本目录,最终静态文件由 Rust 同域 | `src/stream.ts` | 通用组件 WebSocket 鉴权、重连和 renderer 分流 | | `src/overlay.tsx` | 弹幕、礼物/表情和 OBS 自适应渲染 | | `src/songOverlay.tsx` | 点歌快照 reducer、revision 校验与往返滚动 | +| `src/giftEffect.tsx` | 礼物流星、大航海全屏庆祝与视口自适应渲染 | +| `src/giftThemes.ts` | 可扩展礼物特效主题注册表与 CSS 变量 | | `src/pwa.tsx` | install prompt、离线状态、显式更新和敏感状态 blocker | | `src/i18n.tsx` | TOML 语言资源、浏览器回退和运行时切换 | | `src/types.ts` | sanitized API view model 与 overlay settings | @@ -70,3 +72,4 @@ YAML 和项目文档。 - [实时协议](../../docs/protocol.md) - [弹幕姬组件](../../docs/components/danmaku-overlay.md) - [点歌姬组件](../../docs/components/song-request.md) +- [全屏礼物特效](../../docs/components/gift-effect.md) diff --git a/apps/overlay/src/api.ts b/apps/overlay/src/api.ts index c21a77b..2963efa 100644 --- a/apps/overlay/src/api.ts +++ b/apps/overlay/src/api.ts @@ -11,6 +11,7 @@ import type { AuthUser, ComponentSummary, CookieCloudSource, + GiftEffectSettings, Invitation, OverlaySettings, Session, @@ -20,7 +21,12 @@ import type { TotpEnrollment, } from './types' import { normalizeThemeId } from './themes' -import { defaultOverlaySettings, defaultSongRequestSettings } from './types' +import { normalizeGiftEffectThemeId } from './giftThemes' +import { + defaultGiftEffectSettings, + defaultOverlaySettings, + defaultSongRequestSettings, +} from './types' import { currentLanguage, hasTranslation, translate } from './i18n' export class ApiError extends Error { @@ -214,6 +220,22 @@ export function normalizeSongRequestSettings(value: unknown): SongRequestSetting } } +export function normalizeGiftEffectSettings(value: unknown): GiftEffectSettings { + const root = object(value) + const settings = object(root.settings ?? value) + const normal = object(settings.normal) + const high = object(settings.high) + const featured = object(settings.featured) + return { + ...defaultGiftEffectSettings, + ...(settings as Partial), + themeId: normalizeGiftEffectThemeId(settings.themeId), + normal: { ...defaultGiftEffectSettings.normal, ...normal }, + high: { ...defaultGiftEffectSettings.high, ...high }, + featured: { ...defaultGiftEffectSettings.featured, ...featured }, + } +} + export function normalizeSongRequestItem(value: unknown): SongRequestItem | undefined { const item = object(value) const requester = object(item.requester) diff --git a/apps/overlay/src/control.css b/apps/overlay/src/control.css index 912e98b..f9719d6 100644 --- a/apps/overlay/src/control.css +++ b/apps/overlay/src/control.css @@ -27,6 +27,86 @@ gap: 7px; } +.gift-thresholds { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +.gift-tier-heading { + display: grid; + gap: 5px; + margin: 22px 0 10px; +} + +.gift-tier-heading small { + color: var(--app-muted); +} + +.gift-tier-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} + +.gift-tier-grid fieldset { + min-width: 0; + margin: 0; + padding: 16px; + border: 1px solid rgba(111, 228, 211, 0.18); + border-radius: 15px; + background: rgba(4, 29, 39, 0.42); +} + +.gift-tier-grid legend { + padding: 0 7px; + color: #b8f9ec; +} + +.gift-tier-grid label { + display: grid; + gap: 5px; + margin-block: 10px; +} + +.gift-tier-grid label span { + display: flex; + justify-content: space-between; + gap: 10px; +} + +.gift-global-settings { + margin-top: 22px; +} + +.compact-toggle-grid { + margin-top: 18px; +} + +.gift-preview-viewport { + width: min(100%, 960px); + aspect-ratio: 16 / 9; + overflow: hidden; + margin-top: 16px; + border: 1px solid rgba(103, 231, 211, 0.28); + background-color: #091820; + background-image: + linear-gradient(45deg, rgba(113, 191, 181, 0.08) 25%, transparent 25%), + linear-gradient(-45deg, rgba(113, 191, 181, 0.08) 25%, transparent 25%), + linear-gradient(45deg, transparent 75%, rgba(113, 191, 181, 0.08) 75%), + linear-gradient(-45deg, transparent 75%, rgba(113, 191, 181, 0.08) 75%); + background-position: + 0 0, + 0 12px, + 12px -12px, + -12px 0; + background-size: 24px 24px; + box-shadow: inset 0 0 60px rgba(0, 8, 15, 0.46); +} + +.gift-preview-viewport .gift-effect-overlay { + width: 100%; + height: 100%; +} + .song-stat-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); @@ -109,7 +189,8 @@ @media (max-width: 760px) { .limit-grid, - .song-stat-grid { + .song-stat-grid, + .gift-tier-grid { grid-template-columns: 1fr 1fr; } @@ -129,7 +210,9 @@ @media (max-width: 480px) { .limit-grid, - .song-stat-grid { + .song-stat-grid, + .gift-tier-grid, + .gift-thresholds { grid-template-columns: 1fr; } } diff --git a/apps/overlay/src/control.tsx b/apps/overlay/src/control.tsx index 691a3a9..c9de582 100644 --- a/apps/overlay/src/control.tsx +++ b/apps/overlay/src/control.tsx @@ -15,6 +15,7 @@ import { errorMessage, json, normalizeComponents, + normalizeGiftEffectSettings, normalizeInvitations, normalizeSettings, normalizeSongRequestPage, @@ -22,16 +23,24 @@ import { normalizeSource, } from './api' import { Overlay } from './overlay' +import { GiftEffectOverlay } from './giftEffect' +import type { GiftEffectPreviewMode } from './giftEffect' +import { getGiftEffectTheme, giftEffectThemes } from './giftThemes' import { PwaControls, usePwaUpdateBlocker } from './pwa' import { SongRequestOverlay } from './songOverlay' import { getOverlayTheme, overlayThemes } from './themes' import { currentLanguage, LanguageSelect, translate, useI18n } from './i18n' -import { defaultOverlaySettings, defaultSongRequestSettings } from './types' +import { + defaultGiftEffectSettings, + defaultOverlaySettings, + defaultSongRequestSettings, +} from './types' import type { AuthUser, ComponentSettings, ComponentSummary, CookieCloudSource, + GiftEffectSettings, Invitation, OverlaySettings, SongRequestItem, @@ -54,6 +63,10 @@ function isSongRequestKind(kind: string): boolean { return kind === 'song_request' } +function isGiftEffectKind(kind: string): boolean { + return kind === 'gift_effect' +} + type Flash = { kind: 'success' | 'error'; text: string } | undefined function Panel({ @@ -519,6 +532,223 @@ function SongRequestPreview({ settings }: { settings: SongRequestSettings }) { ) } +const giftTiers = ['normal', 'high', 'featured'] as const + +function GiftEffectSettingsEditor({ + settings, + onChange, + onSave, + saving, +}: { + settings: GiftEffectSettings + onChange: (settings: GiftEffectSettings) => void + onSave: () => Promise + saving: boolean +}) { + const edit = (key: K, value: GiftEffectSettings[K]) => + onChange({ ...settings, [key]: value }) + const editTier = ( + tier: (typeof giftTiers)[number], + key: 'count' | 'size' | 'speed', + value: number, + ) => onChange({ ...settings, [tier]: { ...settings[tier], [key]: value } }) + const theme = getGiftEffectTheme(settings.themeId) + return ( +
+
+ +
+
+ {translate('gift.settings.thresholds')} + + +
+
+ {translate('gift.settings.tiers')} + {translate('gift.settings.tiers_description')} +
+
+ {giftTiers.map(tier => ( +
+ {translate(`gift.settings.tier.${tier}`)} + + + +
+ ))} +
+
+ + + + +
+
+ +
+
+ +
+
+ ) +} + +function GiftEffectPreview({ settings }: { settings: GiftEffectSettings }) { + const [mode, setMode] = useState('high') + const [nonce, setNonce] = useState(0) + const trigger = (next: GiftEffectPreviewMode) => { + setMode(next) + setNonce(current => current + 1) + } + return ( + +
+ {(['normal', 'high', 'featured', 'guard'] as const).map(candidate => ( + + ))} +
+
+ +
+
+ ) +} + function SourceEditor({ source, onSaved, @@ -791,14 +1021,24 @@ function ObsAccessPanel({ component }: { component: ComponentSummary }) { ) } -function TestEvents({ componentId }: { componentId: string }) { - const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift'>('danmaku') +function TestEvents({ + componentId, + giftOnly = false, +}: { + componentId: string + giftOnly?: boolean +}) { + const [kind, setKind] = useState<'danmaku' | 'enter' | 'gift' | 'guard'>( + giftOnly ? 'gift' : 'danmaku', + ) const [uid, setUid] = useState('test-viewer') const [name, setName] = useState(() => translate('test.default_viewer')) const [text, setText] = useState(() => translate('test.default_text')) const [giftName, setGiftName] = useState(() => translate('test.default_gift')) const [quantity, setQuantity] = useState(1) const [battery, setBattery] = useState(100) + const [guardName, setGuardName] = useState(() => translate('test.default_guard')) + const [guardPrice, setGuardPrice] = useState(198000) const [flash, setFlash] = useState() const [busy, setBusy] = useState(false) @@ -815,6 +1055,7 @@ function TestEvents({ componentId }: { componentId: string }) { name, ...(kind === 'danmaku' ? { text } : {}), ...(kind === 'gift' ? { giftName, quantity, battery } : {}), + ...(kind === 'guard' ? { guardName, quantity, price: guardPrice } : {}), }), ) setFlash({ kind: 'success', text: translate('test.sent') }) @@ -831,9 +1072,10 @@ function TestEvents({ componentId }: { componentId: string }) { )} + {kind === 'guard' && ( + <> + + + + + )}
+ ) : isGiftEffectKind(selected.kind) && settings ? ( + <> + + setSettings(next)} + onSave={saveSettings} + saving={saving} + /> + + + ) : (
{translate('components.no_editor')}
)} - {isDanmakuKind(selected.kind) && } + {(isDanmakuKind(selected.kind) || isGiftEffectKind(selected.kind)) && ( + + )} )} {!loading && !selected && ( diff --git a/apps/overlay/src/giftEffect.css b/apps/overlay/src/giftEffect.css new file mode 100644 index 0000000..6725628 --- /dev/null +++ b/apps/overlay/src/giftEffect.css @@ -0,0 +1,355 @@ +.gift-effect-overlay { + position: relative; + isolation: isolate; + /* Percentages fill both a real OBS viewport and the nested control preview. */ + width: 100%; + height: 100%; + overflow: hidden; + color: var(--gift-cyan, #a9fff2); + background: transparent; + pointer-events: none; +} + +.meteor-sky, +.meteor-burst { + position: absolute; + inset: 0; + overflow: hidden; +} + +.gift-meteor { + position: absolute; + top: var(--meteor-y); + left: 0; + width: var(--meteor-size); + height: var(--meteor-size); + opacity: 0; + will-change: transform, opacity; + animation: var(--gift-motion-meteor, gift-meteor-flight) var(--meteor-duration) linear + var(--meteor-delay) both; +} + +.meteor-core { + position: absolute; + inset: 0; + z-index: 3; + display: grid; + overflow: hidden; + place-items: center; + border: max(2px, calc(var(--meteor-size) * 0.035)) solid + color-mix(in srgb, var(--gift-jade) 72%, white); + border-radius: 50%; + background: + radial-gradient(circle at 36% 28%, rgba(255, 255, 255, 0.42), transparent 25%), + radial-gradient(circle, rgba(31, 125, 122, 0.94), rgba(2, 24, 39, 0.96)); + box-shadow: + 0 0 calc(var(--meteor-size) * 0.18) var(--gift-cyan), + 0 0 calc(var(--meteor-size) * 0.55) color-mix(in srgb, var(--gift-jade) 56%, transparent), + inset 0 0 calc(var(--meteor-size) * 0.18) rgba(223, 255, 247, 0.45); +} + +.meteor-core::after { + content: ''; + position: absolute; + inset: 5%; + border: 1px solid rgba(255, 236, 173, 0.6); + border-radius: inherit; + box-shadow: inset 0 0 12px rgba(255, 238, 185, 0.25); +} + +.meteor-core img { + width: 82%; + height: 82%; + object-fit: contain; + opacity: 0.96; + filter: blur(0.55px) saturate(0.92) brightness(1.08) + drop-shadow(0 0 7px rgba(255, 238, 178, 0.72)) drop-shadow(0 0 13px rgba(225, 255, 248, 0.48)); +} + +.meteor-fallback { + color: var(--gift-gold); + font-size: calc(var(--meteor-size) * 0.55); + text-shadow: 0 0 12px var(--gift-cyan); +} + +.meteor-tail { + position: absolute; + top: 38%; + right: 48%; + z-index: 1; + width: calc(var(--meteor-size) * 5.4); + height: 24%; + border-radius: 100% 0 0 100%; + opacity: var(--trail-opacity); + background: + linear-gradient( + 90deg, + transparent 0%, + rgba(255, 190, 54, 0.025) 18%, + rgba(255, 199, 67, 0.14) 42%, + rgba(255, 211, 91, 0.5) 72%, + rgba(255, 244, 185, 0.96) 100% + ), + linear-gradient(90deg, transparent 8%, rgba(255, 174, 26, 0.08) 48%, #ffd96d 100%); + filter: blur(calc(var(--meteor-size) * 0.022)) + drop-shadow(0 0 calc(var(--meteor-size) * 0.08) rgba(255, 196, 54, 0.72)); + clip-path: polygon(0 50%, 100% 8%, 100% 92%); +} + +.meteor-tail::after { + content: ''; + position: absolute; + inset: 39% 0; + background: linear-gradient( + 90deg, + transparent, + rgba(255, 205, 75, 0.12) 48%, + rgba(255, 249, 207, 0.92) + ); + box-shadow: 0 0 calc(var(--meteor-size) * 0.06) rgba(255, 218, 100, 0.72); +} + +.tier-high .meteor-tail { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 184, 33, 0.12) 35%, + rgba(255, 211, 77, 0.62) 74%, + #fff2ac + ); +} + +.tier-featured .meteor-tail { + background: linear-gradient( + 90deg, + transparent, + rgba(255, 171, 19, 0.16) 28%, + rgba(255, 207, 62, 0.72) 70%, + #fff9cf + ); + filter: blur(calc(var(--meteor-size) * 0.02)) + drop-shadow(0 0 calc(var(--meteor-size) * 0.12) rgba(255, 207, 55, 0.88)); +} + +.meteor-spark { + position: absolute; + z-index: 2; + width: 12%; + height: 12%; + background: var(--gift-gold); + clip-path: polygon(50% 0, 60% 39%, 100% 50%, 60% 61%, 50% 100%, 40% 61%, 0 50%, 40% 39%); + filter: drop-shadow(0 0 5px var(--gift-cyan)); + animation: gift-meteor-sparkle 720ms ease-in-out infinite alternate; +} + +.meteor-spark-a { + top: -8%; + right: -15%; +} + +.meteor-spark-b { + right: 14%; + bottom: -14%; + width: 8%; + height: 8%; + animation-delay: -360ms; +} + +.guard-celebration { + position: absolute; + inset: 0; + z-index: 20; + display: grid; + overflow: hidden; + place-items: center; + opacity: 0; + color: #edfffa; + background: + radial-gradient(circle at 50% 48%, rgba(30, 138, 131, 0.72), transparent 28%), + radial-gradient(circle at 25% 18%, rgba(63, 101, 172, 0.34), transparent 33%), + radial-gradient(circle at 78% 82%, rgba(96, 47, 116, 0.3), transparent 36%), var(--gift-night); + animation: var(--gift-motion-guard, gift-guard-reveal) var(--guard-duration) ease-in-out both; +} + +.guard-nebula { + position: absolute; + inset: -25%; + background: conic-gradient( + from 90deg, + transparent, + rgba(93, 237, 209, 0.18), + transparent 32%, + rgba(255, 207, 230, 0.12), + transparent 68%, + rgba(255, 230, 156, 0.13), + transparent + ); + filter: blur(28px); + animation: gift-nebula-turn 9s linear infinite; +} + +.guard-stars { + position: absolute; + inset: 0; +} + +.guard-stars i { + position: absolute; + width: var(--star-size); + height: var(--star-size); + opacity: 0.1; + background: linear-gradient(135deg, #fff8ca, var(--gift-cyan) 58%, var(--gift-rose)); + clip-path: polygon(50% 0, 60% 40%, 100% 50%, 60% 60%, 50% 100%, 40% 60%, 0 50%, 40% 40%); + filter: drop-shadow(0 0 6px var(--gift-cyan)); + animation: var(--gift-motion-star, gift-star-pulse) 2.8s ease-in-out var(--star-delay) infinite; +} + +.guard-halo { + position: absolute; + width: min(62vmin, 720px); + aspect-ratio: 1; + border: 1px solid rgba(155, 255, 235, 0.4); + border-radius: 50%; + box-shadow: + 0 0 60px rgba(87, 241, 212, 0.25), + inset 0 0 70px rgba(255, 224, 166, 0.12); + animation: gift-halo-breathe 2.6s ease-in-out infinite; +} + +.guard-halo i { + position: absolute; + inset: 7%; + border: 1px solid rgba(255, 227, 170, 0.38); + border-radius: 45% 55% 48% 52%; + transform: rotate(30deg); +} + +.guard-halo i:nth-child(2) { + inset: 15%; + border-color: rgba(255, 195, 224, 0.32); + transform: rotate(76deg); +} + +.guard-halo i:nth-child(3) { + inset: 23%; + border-color: rgba(123, 246, 224, 0.45); + transform: rotate(122deg); +} + +.guard-copy { + position: relative; + z-index: 3; + display: grid; + max-width: min(82vw, 1000px); + justify-items: center; + gap: clamp(8px, 1.5vh, 20px); + text-align: center; + text-shadow: 0 0 18px rgba(108, 255, 226, 0.72); +} + +.guard-copy span { + color: var(--gift-gold); + font-size: clamp(13px, 1.6vw, 30px); + letter-spacing: 0.45em; +} + +.guard-copy strong { + color: #f3fffc; + font-size: clamp(38px, 7vw, 132px); + font-weight: 500; + letter-spacing: 0.1em; + filter: drop-shadow(0 0 18px rgba(116, 255, 226, 0.54)); +} + +.guard-copy b { + color: var(--gift-rose); + font-size: clamp(16px, 2.3vw, 44px); + font-weight: 500; + letter-spacing: 0.12em; +} + +.gift-low-motion .meteor-spark, +.gift-low-motion .guard-nebula, +.gift-low-motion .guard-halo { + animation: none; +} + +@keyframes gift-meteor-flight { + 0% { + opacity: 0; + transform: translate3d(calc(var(--meteor-size) * -5.5), 0, 0) scale(0.76) rotate(-5deg); + } + 8% { + opacity: 1; + } + 88% { + opacity: 1; + } + 100% { + opacity: 0; + transform: translate3d(calc(100vw + var(--meteor-size) * 2), var(--meteor-drift), 0) scale(1.06) + rotate(8deg); + } +} + +@keyframes gift-meteor-sparkle { + from { + opacity: 0.25; + transform: rotate(0) scale(0.55); + } + to { + opacity: 1; + transform: rotate(45deg) scale(1.2); + } +} + +@keyframes gift-guard-reveal { + 0%, + 100% { + opacity: 0; + } + 8%, + 86% { + opacity: 1; + } +} + +@keyframes gift-star-pulse { + 0%, + 100% { + opacity: 0.08; + transform: rotate(0) scale(0.45); + } + 48% { + opacity: 1; + transform: rotate(50deg) scale(1.32); + } +} + +@keyframes gift-nebula-turn { + to { + transform: rotate(360deg); + } +} + +@keyframes gift-halo-breathe { + 50% { + transform: scale(1.08) rotate(3deg); + box-shadow: + 0 0 110px rgba(87, 241, 212, 0.38), + inset 0 0 90px rgba(255, 224, 166, 0.2); + } +} + +@media (prefers-reduced-motion: reduce) { + .gift-meteor { + animation-duration: max(var(--meteor-duration), 6s); + } + + .meteor-spark, + .guard-nebula, + .guard-stars i, + .guard-halo { + animation: none; + } +} diff --git a/apps/overlay/src/giftEffect.tsx b/apps/overlay/src/giftEffect.tsx new file mode 100644 index 0000000..3168629 --- /dev/null +++ b/apps/overlay/src/giftEffect.tsx @@ -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 +} +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([]) + 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 ✦ + return ( + {gift.name { + 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 ( +
+ {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 ( +
+ + + + + + +
+ ) + })} +
+ ) +} + +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 ( +
+
+ + +
+ {translate('gift.guard_salute')} + {translate('gift.guard_title', { guard })} + {translate('gift.guard_viewer', { viewer })} +
+
+ ) +} + +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(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 ( +
+
+ {remote.effects + .filter(effect => effect.kind === 'gift') + .map(effect => { + const tier = tierFor(effect, settings) + return ( + + ) + })} +
+ {guard && } +
+ ) +} diff --git a/apps/overlay/src/giftThemes.ts b/apps/overlay/src/giftThemes.ts new file mode 100644 index 0000000..1ebbc06 --- /dev/null +++ b/apps/overlay/src/giftThemes.ts @@ -0,0 +1,63 @@ +import type { CSSProperties } from 'react' +import type { GiftEffectThemeId } from './types' + +export type GiftEffectTheme = { + id: GiftEffectThemeId + nameKey: string + descriptionKey: string + className: string + palette: { + jade: string + cyan: string + gold: string + rose: string + night: string + } + motion: { + meteor: string + guardReveal: string + starPulse: string + } +} + +export const giftEffectThemes: readonly GiftEffectTheme[] = [ + { + id: 'jade-starfall', + nameKey: 'gift.theme.jade_starfall.name', + descriptionKey: 'gift.theme.jade_starfall.description', + className: 'gift-theme-jade-starfall', + palette: { + jade: '#72f3d8', + cyan: '#a9fff2', + gold: '#ffe7a4', + rose: '#ffd0e5', + night: '#020c18', + }, + motion: { + meteor: 'gift-meteor-flight', + guardReveal: 'gift-guard-reveal', + starPulse: 'gift-star-pulse', + }, + }, +] + +export function getGiftEffectTheme(id: unknown): GiftEffectTheme { + return giftEffectThemes.find(theme => theme.id === id) ?? giftEffectThemes[0] +} + +export function normalizeGiftEffectThemeId(id: unknown): GiftEffectThemeId { + return getGiftEffectTheme(id).id +} + +export function giftThemeVariables(theme: GiftEffectTheme): CSSProperties { + return { + ['--gift-jade' as string]: theme.palette.jade, + ['--gift-cyan' as string]: theme.palette.cyan, + ['--gift-gold' as string]: theme.palette.gold, + ['--gift-rose' as string]: theme.palette.rose, + ['--gift-night' as string]: theme.palette.night, + ['--gift-motion-meteor' as string]: theme.motion.meteor, + ['--gift-motion-guard' as string]: theme.motion.guardReveal, + ['--gift-motion-star' as string]: theme.motion.starPulse, + } +} diff --git a/apps/overlay/src/main.tsx b/apps/overlay/src/main.tsx index f0e1abc..d5ee459 100644 --- a/apps/overlay/src/main.tsx +++ b/apps/overlay/src/main.tsx @@ -17,6 +17,7 @@ import { InvitationsPage, SongRequestsPage, } from './control' +import { GiftEffectOverlay } from './giftEffect' import { Overlay, tokenFromFragment } from './overlay' import { PwaControls, authRoute, cleanupLegacyPwa, initializePwa } from './pwa' import { SongRequestOverlay } from './songOverlay' @@ -25,6 +26,7 @@ import { I18nProvider, translate, useI18n } from './i18n' import type { Session } from './types' import './style.css' import './control.css' +import './giftEffect.css' import './song.css' function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken: string }) { @@ -38,6 +40,7 @@ function ObsComponent({ publicId, accessToken }: { publicId: string; accessToken if (stream.connection === 'denied') return
{t('main.obs_invalid_token')}
if (stream.componentKind === 'song_request') return + if (stream.componentKind === 'gift_effect') return if (stream.componentKind === 'danmaku_overlay' || stream.componentKind === 'danmaku') return return
{t('main.obs_connecting')}
diff --git a/apps/overlay/src/types.ts b/apps/overlay/src/types.ts index b115fb3..a4f3390 100644 --- a/apps/overlay/src/types.ts +++ b/apps/overlay/src/types.ts @@ -71,7 +71,44 @@ export const defaultSongRequestSettings: SongRequestSettings = { requestCooldownSeconds: 0, } -export type ComponentSettings = OverlaySettings | SongRequestSettings +export type GiftEffectThemeId = 'jade-starfall' + +export type MeteorTierSettings = { + count: number + size: number + speed: number +} + +/** Full-viewport visual settings for gift meteors and guard celebrations. */ +export type GiftEffectSettings = { + themeId: GiftEffectThemeId + highValueThreshold: number + featuredValueThreshold: number + normal: MeteorTierSettings + high: MeteorTierSettings + featured: MeteorTierSettings + trailIntensity: number + guardStarCount: number + guardEffectDurationMs: number + maxConcurrentEffects: number + lowPerformanceMode: boolean +} + +export const defaultGiftEffectSettings: GiftEffectSettings = { + themeId: 'jade-starfall', + highValueThreshold: 10_000, + featuredValueThreshold: 100_000, + normal: { count: 3, size: 88, speed: 560 }, + high: { count: 6, size: 126, speed: 720 }, + featured: { count: 10, size: 168, speed: 880 }, + trailIntensity: 78, + guardStarCount: 48, + guardEffectDurationMs: 5_200, + maxConcurrentEffects: 8, + lowPerformanceMode: false, +} + +export type ComponentSettings = OverlaySettings | SongRequestSettings | GiftEffectSettings export type SongRequester = { uid: string; name: string } diff --git a/apps/server-rust/README.md b/apps/server-rust/README.md index 6acc947..79e38b5 100644 --- a/apps/server-rust/README.md +++ b/apps/server-rust/README.md @@ -22,6 +22,7 @@ crate 导出。 | `realtime` | account event routing 与 component-scoped fanout | | `repository` | PostgreSQL component facade 和热路径缓存同步 | | `song_request` | 点歌命令、事务队列、评分、快照和管理服务 | +| `gift_effect` | 全屏礼物流星设置、分档边界与事件订阅 | ## 重要不变量 diff --git a/apps/server-rust/migrations/009_gift_effect.sql b/apps/server-rust/migrations/009_gift_effect.sql new file mode 100644 index 0000000..8fe95f8 --- /dev/null +++ b/apps/server-rust/migrations/009_gift_effect.sql @@ -0,0 +1,7 @@ +-- Every account owns exactly one built-in full-screen gift-effect component. +-- Existing accounts are backfilled from the validated Rust defaults during +-- startup; the partial index keeps concurrent starts idempotent. + +CREATE UNIQUE INDEX IF NOT EXISTS component_instances_single_gift_effect + ON component_instances(owner_user_id, kind) + WHERE kind = 'gift_effect'; diff --git a/apps/server-rust/src/app.rs b/apps/server-rust/src/app.rs index 448b924..b7a875c 100644 --- a/apps/server-rust/src/app.rs +++ b/apps/server-rust/src/app.rs @@ -81,7 +81,7 @@ impl AppState { let repository = TenantRepository::new(db.clone(), registry.clone(), component_cache.clone()); repository - .ensure_song_request_components() + .ensure_builtin_components() .await .map_err(|error| error.to_string())?; repository @@ -291,6 +291,7 @@ async fn migrate(db: &Db) -> Result<(), String> { 8_i32, include_str!("../migrations/008_account_language.sql"), ), + (9_i32, include_str!("../migrations/009_gift_effect.sql")), ] { let applied = transaction .query_one( diff --git a/apps/server-rust/src/auth.rs b/apps/server-rust/src/auth.rs index ddbd54d..f18e442 100644 --- a/apps/server-rust/src/auth.rs +++ b/apps/server-rust/src/auth.rs @@ -26,6 +26,7 @@ use uuid::Uuid; use crate::{ credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host}, db::{Db, DbError}, + gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings}, i18n, overlay::OverlaySettings, song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings}, @@ -650,6 +651,23 @@ impl AuthService { &[&user_id, &song_component_id], ) .await?; + let gift_component_id = Uuid::new_v4(); + let gift_settings = serde_json::to_value(GiftEffectSettings::default()) + .expect("GiftEffectSettings is always JSON serializable"); + transaction + .execute( + "INSERT INTO component_instances \ + (id,owner_user_id,kind,name,settings,settings_version,enabled) \ + VALUES($1,$2,$3,$4,$5,1,true)", + &[ + &gift_component_id, + &user_id, + &GIFT_EFFECT_KIND, + &GIFT_EFFECT_NAME, + &gift_settings, + ], + ) + .await?; transaction .execute( "DELETE FROM pending_registrations WHERE id=$1", diff --git a/apps/server-rust/src/components.rs b/apps/server-rust/src/components.rs index 2fe9892..facffc9 100644 --- a/apps/server-rust/src/components.rs +++ b/apps/server-rust/src/components.rs @@ -20,6 +20,7 @@ use uuid::Uuid; use crate::{ domain::{ComponentMessage, LiveEvent, LiveEventKind}, + gift_effect::GiftEffectDefinition, overlay::OverlaySettings, song_request::{SongRequestDefinition, SongRequestProjection}, }; @@ -382,6 +383,12 @@ impl ComponentRegistry { ) .expect("built-in component kinds are unique"); registry + .register( + Arc::new(GiftEffectDefinition), + Arc::new(PassthroughProjection), + ) + .expect("built-in component kinds are unique"); + registry } pub fn register( @@ -548,6 +555,25 @@ mod tests { assert!(!subscriptions.contains(LiveEventKind::Gift)); } + #[test] + fn builtin_gift_effect_is_registered_with_guard_and_gift_subscriptions() { + let registry = ComponentRegistry::default(); + assert!(registry.kinds().contains(&"gift_effect".to_owned())); + let runtime = registry.runtime("gift_effect").unwrap(); + let instance = ComponentInstance::new( + Uuid::new_v4(), + Uuid::new_v4(), + "gift_effect", + "礼物星雨", + 1, + runtime.definition().default_settings(), + ); + let subscriptions = runtime.subscriptions(&instance).unwrap(); + assert!(subscriptions.contains(LiveEventKind::Gift)); + assert!(subscriptions.contains(LiveEventKind::GuardPurchase)); + assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); + } + #[test] fn duplicate_component_kinds_are_rejected() { let registry = ComponentRegistry::default(); diff --git a/apps/server-rust/src/gift_effect.rs b/apps/server-rust/src/gift_effect.rs new file mode 100644 index 0000000..e525ba1 --- /dev/null +++ b/apps/server-rust/src/gift_effect.rs @@ -0,0 +1,178 @@ +//! Full-screen gift and membership effect component. +//! +//! The component is deliberately passive: it subscribes to canonical gift and +//! guard-purchase events and projects them to its own authenticated OBS stream. +//! All visual differentiation is settings-driven in the browser, so receiving +//! an effect never creates database writes or depends on an OBS connection. + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::{ + components::{ComponentDefinition, ComponentError, EventSubscription}, + domain::LiveEventKind, +}; + +pub const GIFT_EFFECT_KIND: &str = "gift_effect"; +pub const GIFT_EFFECT_NAME: &str = "礼物星雨"; + +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "kebab-case")] +pub enum GiftEffectThemeId { + #[default] + JadeStarfall, +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MeteorTierSettings { + pub count: u8, + pub size: u16, + pub speed: u16, +} + +impl MeteorTierSettings { + fn sanitize(mut self) -> Self { + self.count = self.count.clamp(1, 24); + self.size = self.size.clamp(24, 480); + self.speed = self.speed.clamp(100, 2_500); + self + } +} + +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GiftEffectSettings { + #[serde(default)] + pub theme_id: GiftEffectThemeId, + pub high_value_threshold: i64, + pub featured_value_threshold: i64, + pub normal: MeteorTierSettings, + pub high: MeteorTierSettings, + pub featured: MeteorTierSettings, + pub trail_intensity: u8, + pub guard_star_count: u8, + pub guard_effect_duration_ms: u16, + pub max_concurrent_effects: u8, + pub low_performance_mode: bool, +} + +impl Default for GiftEffectSettings { + fn default() -> Self { + Self { + theme_id: GiftEffectThemeId::default(), + high_value_threshold: 10_000, + featured_value_threshold: 100_000, + normal: MeteorTierSettings { + count: 3, + size: 88, + speed: 560, + }, + high: MeteorTierSettings { + count: 6, + size: 126, + speed: 720, + }, + featured: MeteorTierSettings { + count: 10, + size: 168, + speed: 880, + }, + trail_intensity: 78, + guard_star_count: 48, + guard_effect_duration_ms: 5_200, + max_concurrent_effects: 8, + low_performance_mode: false, + } + } +} + +impl GiftEffectSettings { + pub fn sanitize(mut self) -> Self { + self.high_value_threshold = self.high_value_threshold.max(0); + self.featured_value_threshold = + self.featured_value_threshold.max(self.high_value_threshold); + self.normal = self.normal.sanitize(); + self.high = self.high.sanitize(); + self.featured = self.featured.sanitize(); + self.trail_intensity = self.trail_intensity.min(100); + self.guard_star_count = self.guard_star_count.clamp(8, 96); + self.guard_effect_duration_ms = self.guard_effect_duration_ms.clamp(1_000, 15_000); + self.max_concurrent_effects = self.max_concurrent_effects.clamp(1, 12); + self + } +} + +pub struct GiftEffectDefinition; + +impl GiftEffectDefinition { + fn parse(&self, settings: Value) -> Result { + serde_json::from_value(settings).map_err(|error| ComponentError::InvalidSettings { + kind: GIFT_EFFECT_KIND.to_owned(), + detail: error.to_string(), + }) + } +} + +impl ComponentDefinition for GiftEffectDefinition { + fn kind(&self) -> &'static str { + GIFT_EFFECT_KIND + } + + fn settings_version(&self) -> u32 { + 1 + } + + fn default_settings(&self) -> Value { + serde_json::to_value(GiftEffectSettings::default()) + .expect("GiftEffectSettings is always JSON serializable") + } + + fn validate_settings(&self, settings: Value) -> Result { + serde_json::to_value(self.parse(settings)?.sanitize()).map_err(|error| { + ComponentError::InvalidSettings { + kind: GIFT_EFFECT_KIND.to_owned(), + detail: error.to_string(), + } + }) + } + + fn subscriptions(&self, settings: &Value) -> Result { + self.parse(settings.clone())?; + Ok(EventSubscription::new([ + LiveEventKind::Gift, + LiveEventKind::GuardPurchase, + ])) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn settings_bound_each_value_tier_and_keep_threshold_order() { + let definition = GiftEffectDefinition; + let mut settings = definition.default_settings(); + settings["normal"]["count"] = Value::from(0); + settings["featured"]["size"] = Value::from(9_999); + settings["highValueThreshold"] = Value::from(50_000); + settings["featuredValueThreshold"] = Value::from(10_000); + let sanitized = definition.validate_settings(settings).unwrap(); + assert_eq!(sanitized["normal"]["count"], 1); + assert_eq!(sanitized["featured"]["size"], 480); + assert_eq!(sanitized["featuredValueThreshold"], 50_000); + } + + #[test] + fn component_only_subscribes_to_durable_gifts_and_guards() { + let definition = GiftEffectDefinition; + let subscriptions = definition + .subscriptions(&definition.default_settings()) + .unwrap(); + assert!(subscriptions.contains(LiveEventKind::Gift)); + assert!(subscriptions.contains(LiveEventKind::GuardPurchase)); + assert!(!subscriptions.contains(LiveEventKind::GiftCombo)); + assert!(!subscriptions.contains(LiveEventKind::Danmaku)); + } +} diff --git a/apps/server-rust/src/http_api.rs b/apps/server-rust/src/http_api.rs index ed775b7..c5ca832 100644 --- a/apps/server-rust/src/http_api.rs +++ b/apps/server-rust/src/http_api.rs @@ -34,7 +34,7 @@ use crate::{ credentials::{CookieCloudCredentials, CookieCloudSecrets, fetch_bilibili_cookie}, domain::{ COMPONENT_PROTOCOL_VERSION, ComponentMessage, DanmakuEvent, DanmakuSegment, EnterEvent, - GiftDetails, GiftEvent, LiveEvent, LiveEventPayload, PlatformViewer, + GiftDetails, GiftEvent, GuardPurchaseEvent, LiveEvent, LiveEventPayload, PlatformViewer, }, repository::{ComponentView, RepositoryError}, song_request::{SONG_REQUEST_KIND, SongListScope, SongRequestError}, @@ -910,6 +910,14 @@ enum TestEventRequest { battery: i32, quantity: i32, }, + Guard { + uid: String, + name: String, + #[serde(rename = "guardName")] + guard_name: String, + quantity: i32, + price: i64, + }, } async fn component_test_event( @@ -959,6 +967,18 @@ async fn component_test_event( source_event_id: format!("test-{}", Uuid::new_v4()), }) } + TestEventRequest::Guard { + uid, + name, + guard_name, + quantity, + price, + } => LiveEventPayload::GuardPurchase(GuardPurchaseEvent { + viewer: viewer(uid, name), + guard_name, + quantity: quantity.max(1), + price: price.max(0), + }), }; let mut event = LiveEvent::new( component.owner_id, diff --git a/apps/server-rust/src/lib.rs b/apps/server-rust/src/lib.rs index 895ebb9..fcbebd2 100644 --- a/apps/server-rust/src/lib.rs +++ b/apps/server-rust/src/lib.rs @@ -12,6 +12,7 @@ pub mod config; pub mod credentials; pub mod db; pub mod domain; +pub mod gift_effect; pub mod http_api; pub mod i18n; pub mod live; diff --git a/apps/server-rust/src/repository.rs b/apps/server-rust/src/repository.rs index a885238..7304872 100644 --- a/apps/server-rust/src/repository.rs +++ b/apps/server-rust/src/repository.rs @@ -15,6 +15,7 @@ use uuid::Uuid; use crate::{ components::{ComponentInstance, ComponentRegistry}, db::{ComponentRecord, Db, DbError}, + gift_effect::{GIFT_EFFECT_KIND, GIFT_EFFECT_NAME, GiftEffectSettings}, i18n, realtime::InMemoryComponentStore, song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings}, @@ -47,10 +48,10 @@ impl TenantRepository { Ok(()) } - /// Ensure every active tenant has the built-in singleton song component. - /// The partial unique index makes this safe across concurrent application - /// starts; the state row is repaired independently for existing instances. - pub async fn ensure_song_request_components(&self) -> Result<(), RepositoryError> { + /// Ensure every active tenant has every required singleton component. + /// Partial unique indexes make concurrent starts idempotent. The song + /// component additionally owns a relational revision state row. + pub async fn ensure_builtin_components(&self) -> Result<(), RepositoryError> { for tenant in self.db.list_active_tenants().await? { let mut client = self.db.get().await?; let transaction = client.transaction().await?; @@ -96,6 +97,22 @@ impl TenantRepository { &[&tenant.user_id, &component_id], ) .await?; + let gift_settings = serde_json::to_value(GiftEffectSettings::default()) + .map_err(|error| RepositoryError::Invalid(error.to_string()))?; + transaction + .execute( + "INSERT INTO component_instances \ + (id,owner_user_id,kind,name,settings,settings_version,enabled) \ + VALUES($1,$2,$3,$4,$5,1,true) ON CONFLICT DO NOTHING", + &[ + &Uuid::new_v4(), + &tenant.user_id, + &GIFT_EFFECT_KIND, + &GIFT_EFFECT_NAME, + &gift_settings, + ], + ) + .await?; transaction.commit().await?; } Ok(()) @@ -131,7 +148,7 @@ impl TenantRepository { // Every tenant receives this singleton during registration/startup. // Keeping creation internal prevents a second instance from racing the // partial unique index and turning a domain conflict into a DB error. - if kind == SONG_REQUEST_KIND { + if matches!(kind, SONG_REQUEST_KIND | GIFT_EFFECT_KIND) { return Err(RepositoryError::Forbidden); } let runtime = self @@ -192,7 +209,7 @@ impl TenantRepository { .await? .ok_or(RepositoryError::NotFound)? .get(0); - if kind == SONG_REQUEST_KIND { + if matches!(kind.as_str(), SONG_REQUEST_KIND | GIFT_EFFECT_KIND) { return Err(RepositoryError::Forbidden); } let changed = transaction diff --git a/docs/components/README.md b/docs/components/README.md index fc64de9..b23ab63 100644 --- a/docs/components/README.md +++ b/docs/components/README.md @@ -1,7 +1,7 @@ # 组件开发指南 -组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay` 与 -`song_request`,未来礼物墙或统计组件也应使用同一套契约。 +组件是“消费所属账户事件流的独立功能实例”。当前内建 `danmaku_overlay`、 `song_request` 与 +`gift_effect`,未来礼物墙或统计组件也应使用同一套契约。 ## 一个组件由什么组成 @@ -56,4 +56,4 @@ Handler 面向“业务事实”。例如点歌请求、礼物累计或审计写 - 组件 WebSocket 不得暴露其他组件列表或控制 API。 当前组件的具体行为见 [`danmaku-overlay.md`](danmaku-overlay.md) 与 -[`song-request.md`](song-request.md)。 +[`song-request.md`](song-request.md)、[`gift-effect.md`](gift-effect.md)。 diff --git a/docs/components/gift-effect.md b/docs/components/gift-effect.md new file mode 100644 index 0000000..5d0bca9 --- /dev/null +++ b/docs/components/gift-effect.md @@ -0,0 +1,30 @@ +# 全屏礼物特效组件 + +`gift_effect` 是每个账户自动拥有且不可删除的单例组件。它消费账户级直播源中的 `live.gift` 和 +`live.guard.buy`,使用独立只读 token 作为透明 OBS 浏览器源;不订阅 +`live.gift.combo`,避免一次连击重复触发完整特效。 + +## 展示行为 + +普通礼物从视口左侧生成一组带青玉、金色星尘拖尾的流星,礼物图片或 GIF 是流星主体,并从右侧完全飞出。原始价值按 +`highValueThreshold` 和 `featuredValueThreshold` +分为普通、高价、特别高价三档,每档分别设置流星数量、基准尺寸和飞行速度。图片加载失败时使用内置星光图形,不依赖外部主题素材。 + +舰长、提督或总督使用同一个 `live.guard.buy` +路径,临时显示不透明星河、闪耀粒子、身份和用户昵称。多个普通礼物可并发;大航海展示选择最新事件。 + +## 尺寸与性能 + +OBS 建议从 `1920×1080` 开始,但渲染器没有固定画布。`ResizeObserver` +按浏览器源实际宽高缩放流星,宽屏、竖屏或自定义分辨率都保持全视口透明。控制台可调整: + +- 三档数量、尺寸和速度; +- 拖尾强度和最大并发特效数; +- 大航海星数和全屏持续时间; +- 低性能模式(限制粒子和流星数量)。 + +## 扩展主题 + +后端设置的 `themeId` 使用稳定 kebab-case ID。前端主题统一在 `apps/overlay/src/giftThemes.ts` +注册颜色、动效标识和本地化资源键;主题 CSS 只作用于 +`.gift-effect-overlay`,不得改变页面根背景或控制台。新增主题时同时扩展 Rust/TypeScript 类型、设置迁移、两种语言资源和降级测试。 diff --git a/docs/protocol.md b/docs/protocol.md index f8c8a7c..5481dd5 100644 --- a/docs/protocol.md +++ b/docs/protocol.md @@ -110,6 +110,11 @@ close 结束连接。 `live.gift` 与 `live.gift.combo` 不是两笔礼物。需要持久化计数的组件通常只消费 `live.gift`;连击事件用于更新同一张视觉卡片。 +`gift_effect` 只订阅 `live.gift` 与 `live.guard.buy`。礼物档位由 sanitized settings 中的 +`highValueThreshold` 和 `featuredValueThreshold` 决定;浏览器使用 `gift.imageUrl` 或 +`gift.animationUrl` +作为流星主体,图片失效时必须使用本地星光占位。该组件不维护状态快照,重连后只展示新到达的实时事件。 + ## 表情分段 `live.danmaku.payload.segments` 是判别联合: diff --git a/resources/i18n.toml b/resources/i18n.toml index 7a7a9f3..e2b8ee3 100644 --- a/resources/i18n.toml +++ b/resources/i18n.toml @@ -212,6 +212,7 @@ name = "简体中文" "test.default_viewer" = "测试观众" "test.default_text" = "今天也要闪闪发光!" "test.default_gift" = "小花花" +"test.default_guard" = "舰长" "test.sent" = "测试事件已发送到当前组件。" "test.failed" = "测试事件发送失败" "test.title" = "事件测试" @@ -224,6 +225,9 @@ name = "简体中文" "test.gift_name" = "礼物名称" "test.quantity" = "数量" "test.battery" = "电池数" +"test.guard" = "上舰" +"test.guard_name" = "舰队身份" +"test.price" = "价值(电池)" "test.sending" = "正在发送…" "test.trigger" = "触发测试事件" "components.title" = "我的组件" @@ -232,11 +236,13 @@ name = "简体中文" "components.empty_description" = "账户初始化完成后,服务会为你创建默认弹幕姬。" "components.danmaku_mark" = "弹" "components.song_mark" = "歌" +"components.gift_mark" = "礼" "components.generic_mark" = "件" "components.danmaku_type" = "直播弹幕姬" "components.song_type" = "直播点歌姬" +"components.gift_type" = "全屏礼物星雨" "components.coming_soon" = "即将支持" -"components.future" = "礼物展示 · 点唱互动" +"components.future" = "更多主题 · 互动组件" "components.settings_blocker" = "保存或还原当前组件设置" "components.settings_load_failed" = "无法读取组件设置" "components.load_failed" = "控制台数据加载失败" @@ -247,6 +253,8 @@ name = "简体中文" "components.danmaku_description" = "每一项都独立保存在当前用户的组件下。" "components.song_settings" = "点歌姬设置" "components.song_description" = "观众发送「点歌 歌名」入队,发送「打分 1-5」评价当前歌曲。" +"components.gift_settings" = "全屏礼物特效设置" +"components.gift_description" = "礼物化作流星横跨透明画布;舰长、提督和总督触发全屏星光献礼。" "components.open_song_stats" = "打开点歌统计" "components.generic_settings" = "组件设置" "components.no_editor" = "该组件类型的设置编辑器尚未安装。" @@ -351,6 +359,38 @@ name = "简体中文" "song.overlay.queue_empty" = "下一首,会由谁来点呢?" "theme.jade_scroll.name" = "青玉花卷" "theme.jade_scroll.description" = "暗蓝青玉玻璃、古风花纹、星花粒子与横向卷轴展开。" +"gift.theme.jade_starfall.name" = "青玉星落" +"gift.theme.jade_starfall.description" = "青玉、流光花卷与金色星尘组成的古风礼物流星。" +"gift.settings.thresholds" = "礼物价值分档(原始价格,1000 = 1 元)" +"gift.settings.high_threshold" = "高价值阈值" +"gift.settings.featured_threshold" = "特别高价值阈值" +"gift.settings.tiers" = "分档流星参数" +"gift.settings.tiers_description" = "流星大小以 1920×1080 为参考,其他 OBS 尺寸会按实际视口自适应。" +"gift.settings.tier.normal" = "普通礼物" +"gift.settings.tier.high" = "高价礼物" +"gift.settings.tier.featured" = "特别高价礼物" +"gift.settings.count" = "流星数量" +"gift.settings.size" = "礼物主体大小" +"gift.settings.speed" = "飞行速度" +"gift.settings.trail" = "拖尾强度" +"gift.settings.guard_stars" = "大航海星光数量" +"gift.settings.guard_duration" = "大航海全屏时长" +"gift.settings.concurrent" = "最大同时特效数" +"gift.preview.title" = "全屏礼物特效预览" +"gift.preview.description" = "预览按 16:9 缩放显示;OBS 浏览器源默认可用 1920×1080,也可使用任意分辨率。" +"gift.preview.normal_button" = "预览普通礼物" +"gift.preview.high_button" = "预览高价礼物" +"gift.preview.featured_button" = "预览特别礼物" +"gift.preview.guard_button" = "预览舰长特效" +"gift.preview.viewer" = "星光观众" +"gift.preview.normal" = "小花花" +"gift.preview.high" = "青玉献礼" +"gift.preview.featured" = "星河之梦" +"gift.preview.guard" = "舰长" +"gift.guard_aria" = "大航海全屏庆祝特效" +"gift.guard_salute" = "星河为你闪耀" +"gift.guard_title" = "{guard}·星光献礼" +"gift.guard_viewer" = "感谢 {viewer} 的守护" "pwa.blocked" = "暂时不能更新,请先处理以下内容:\n\n{reasons}" "pwa.confirm_update" = "更新会刷新控制台。请先保存设置、邀请码、恢复码或刚轮换的 OBS 令牌,确定现在更新吗?" "pwa.offline" = "离线" @@ -570,6 +610,7 @@ name = "English" "test.default_viewer" = "Test viewer" "test.default_text" = "Shine brightly today!" "test.default_gift" = "Little flower" +"test.default_guard" = "Captain" "test.sent" = "The test event was sent to this component." "test.failed" = "Could not send the test event" "test.title" = "Event testing" @@ -582,6 +623,9 @@ name = "English" "test.gift_name" = "Gift name" "test.quantity" = "Quantity" "test.battery" = "Battery value" +"test.guard" = "Guard purchase" +"test.guard_name" = "Guard tier" +"test.price" = "Value (battery)" "test.sending" = "Sending…" "test.trigger" = "Trigger test event" "components.title" = "My components" @@ -590,11 +634,13 @@ name = "English" "components.empty_description" = "The service creates a default chat overlay after account initialization." "components.danmaku_mark" = "Chat" "components.song_mark" = "Song" +"components.gift_mark" = "Gift" "components.generic_mark" = "App" "components.danmaku_type" = "Live chat overlay" "components.song_type" = "Song request overlay" +"components.gift_type" = "Full-screen gift starfall" "components.coming_soon" = "Coming soon" -"components.future" = "Gift showcase · Song interaction" +"components.future" = "More themes · Interactive components" "components.settings_blocker" = "save or revert the current component settings" "components.settings_load_failed" = "Could not load component settings" "components.load_failed" = "Could not load console data" @@ -605,6 +651,8 @@ name = "English" "components.danmaku_description" = "Every setting is stored independently on this user's component." "components.song_settings" = "Song request settings" "components.song_description" = "Viewers send “点歌 Song name” to queue a song and “打分 1-5” to rate the current song." +"components.gift_settings" = "Full-screen gift effect settings" +"components.gift_description" = "Gifts become meteors crossing a transparent canvas; Guard, Admiral, and Governor purchases trigger a full-screen starlight celebration." "components.open_song_stats" = "Open song statistics" "components.generic_settings" = "Component settings" "components.no_editor" = "No settings editor is installed for this component kind." @@ -709,6 +757,38 @@ name = "English" "song.overlay.queue_empty" = "Who will request the next song?" "theme.jade_scroll.name" = "Jade Blossom Scroll" "theme.jade_scroll.description" = "Dark jade glass, classical floral patterns, starlight particles, and a horizontal scroll reveal." +"gift.theme.jade_starfall.name" = "Jade Starfall" +"gift.theme.jade_starfall.description" = "Traditional jade, luminous floral scrollwork, and golden stardust shape each gift meteor." +"gift.settings.thresholds" = "Gift value tiers (raw price, 1000 = CNY 1)" +"gift.settings.high_threshold" = "High-value threshold" +"gift.settings.featured_threshold" = "Featured-value threshold" +"gift.settings.tiers" = "Meteor settings by tier" +"gift.settings.tiers_description" = "Sizes use 1920×1080 as a reference; other OBS dimensions scale responsively to their actual viewport." +"gift.settings.tier.normal" = "Normal gift" +"gift.settings.tier.high" = "High-value gift" +"gift.settings.tier.featured" = "Featured gift" +"gift.settings.count" = "Meteor count" +"gift.settings.size" = "Gift core size" +"gift.settings.speed" = "Flight speed" +"gift.settings.trail" = "Trail intensity" +"gift.settings.guard_stars" = "Membership star count" +"gift.settings.guard_duration" = "Membership full-screen duration" +"gift.settings.concurrent" = "Maximum concurrent effects" +"gift.preview.title" = "Full-screen gift effect preview" +"gift.preview.description" = "The preview is scaled to 16:9. OBS browser sources may use the 1920×1080 default or any other resolution." +"gift.preview.normal_button" = "Preview normal gift" +"gift.preview.high_button" = "Preview high-value gift" +"gift.preview.featured_button" = "Preview featured gift" +"gift.preview.guard_button" = "Preview Guard effect" +"gift.preview.viewer" = "Starlight viewer" +"gift.preview.normal" = "Little flower" +"gift.preview.high" = "Jade offering" +"gift.preview.featured" = "Dream of the Stars" +"gift.preview.guard" = "Guard" +"gift.guard_aria" = "Full-screen membership celebration" +"gift.guard_salute" = "THE STARS SHINE FOR YOU" +"gift.guard_title" = "{guard} · STARLIGHT TRIBUTE" +"gift.guard_viewer" = "Thank you, {viewer}, for your support" "pwa.blocked" = "The update is blocked. Finish these items first:\n\n{reasons}" "pwa.confirm_update" = "Updating refreshes the console. Save settings, invitations, recovery codes, and newly rotated OBS tokens first. Update now?" "pwa.offline" = "Offline"