v0.1.13: 金碧山河(SVG/Canvas 素材 + 动效系统 + 舞美重装)

【素材生成(零外部资源)】
- scripts/gen-art.mjs 程序化产出 5 枚 SVG 资产:金碧山水全景(山脊/金乌/雁阵/水纹)、
  祥云纹带、回纹角饰、仙鹤、星辰层 → src/renderer/assets/gen/,npm run gen:art 可重跑

【Canvas 动效系统(单 RAF)】
- ui/fx-canvas.ts:灵雾粒子流(常驻随季色)/金砂喷发(spark)/刀光(blade)/涟漪(ripple),
  单实例防重入、防膨胀 cap、GPU 友好、visibility 自停
- ui/fx.ts + core/fxqueue.ts:FxGate 统一发射闸——档位过滤(柔/标/全)、
  reduced-motion 降级、单帧批处理防竞态、deltas 纯函数(漂字差分)

【舞美】
- 金碧背景层 bg-scene(全页面,随季节 hue-rotate 色温渐变)
- 顶栏时辰司:季节印(春·熏风/夏·蝉鸣/秋·霜叶/冬·雪静)+年月进度环(SVG 弧段)+节气名
- 成员卡:闭关 aura 三点浮动 + 修为流光 shimmer
- 消息流:good 事件(突破/获宝/落槌)→金砂喷发,战事→刀光扫过战报
- Boot 卷轴展开过渡;drift 漂字层(独立节点队列不覆盖,8 条裁剪)
- 设置页动效三档(柔和/标准/全开)

【竞态规避】
- RAF 单循环 guard、particles cap 裁剪、FxGate draining 互斥、漂字节点附加不覆盖、
  visibility 自动降级、advance in-flight 闸沿用

【测试】937→967(timesense 矩阵 15 + FxGate 竞态/档位/deltas 15)
全量 35 套件通过;金钟罩零漂移(纯 UI/素材层);typecheck/build 通过
This commit is contained in:
2026-08-23 12:50:18 +08:00
parent 607624695d
commit c52ecffcd9
20 changed files with 904 additions and 6 deletions
+72
View File
@@ -0,0 +1,72 @@
export type FxKind = 'spark' | 'mist' | 'ripple' | 'blade' | 'pulse' | 'seasonShift' | 'drift'
export type FxMode = 'soft' | 'std' | 'full'
export interface FxEmit {
kind: FxKind
payload?: Record<string, number | string>
at: number
}
export interface FxBus {
emit(e: FxEmit): void
}
/** 竞态安全发射闸:单循环、队列不覆盖、档位过滤、reduced-motion 降级 */
export class FxGate {
private queue: FxEmit[] = []
private draining = false
mode: FxMode = 'std'
preferReduced = false
constructor(private bus: FxBus) {}
setMode(m: FxMode): void {
this.mode = m
}
setReduced(r: boolean): void {
this.preferReduced = r
}
allowed(kind: FxKind): boolean {
if (this.mode === 'soft') return kind === 'ripple' || kind === 'pulse' || kind === 'drift'
if (this.mode === 'full') return true
return kind !== 'blade' // std:火花/雾/涟漪/脉动,刀光仅 full
}
emit(kind: FxKind, payload?: FxEmit['payload']): void {
if (!this.allowed(kind) || this.preferReduced || this.draining) return
this.queue.push({ kind, payload, at: Date.now() })
this.drain()
}
private drain(): void {
if (this.draining) return
this.draining = true
// 单帧集中派发(不逐个 setTimeout,防止栈深+竞态覆盖)
const batch = this.queue
this.queue = []
try {
for (const e of batch) this.bus.emit(e)
} finally {
this.draining = false
}
}
pending(): number {
return this.queue.length + (this.draining ? 1 : 0)
}
/** 纯逻辑:资源差量(防负值与 NaN,供漂字用) */
static deltas(prev: Record<string, number>, next: Record<string, number>): Record<string, number> {
const out: Record<string, number> = {}
const keys = new Set([...Object.keys(prev), ...Object.keys(next)])
for (const k of keys) {
const a = prev[k] ?? 0
const b = next[k] ?? 0
if (Number.isFinite(a) && Number.isFinite(b) && b !== a) out[k] = b - a
}
return out
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Season } from '../data/season'
export const TIDES: string[] = [
'立春', '雨水', '惊蛰', '春分', '清明', '谷雨',
'立夏', '小满', '芒种', '夏至', '小暑', '大暑',
'立秋', '处暑', '白露', '秋分', '寒露', '霜降',
'立冬', '小雪', '大雪', '冬至', '小寒', '大寒'
]
/** 季节 → 节气(每月两个节气:tideOf(month, phase) 返回 0..1 */
export function tideOf(month: number, phase: 0 | 1): string {
const idx = Math.min(23, (month - 1) * 2 + phase)
return TIDES[idx] ?? '立春'
}
/** 年轮进度(0..1)——用于顶栏年环弧段 */
export function yearRing(month: number): number {
return (month - 1) / 12
}
export interface SeasonTint {
accent: string
tint: string
glow: string
name: string
desc: string
}
const TINTS: Record<Season, SeasonTint> = {
spring: { accent: '#9cc27c', tint: '#7a9a5c', glow: '114, 178, 106', name: '春·熏风', desc: '草木萌芽,灵田争长。' },
summer: { accent: '#d8b35f', tint: '#b48c46', glow: '216, 172, 96', name: '夏·蝉鸣', desc: '心火正旺,修炼如炉。' },
autumn: { accent: '#d0954f', tint: '#a97a3e', glow: '212, 148, 84', name: '秋·霜叶', desc: '市集兴旺,收获正忙。' },
winter: { accent: '#7f9eb5', tint: '#5c7d94', glow: '127, 158, 181', name: '冬·雪静', desc: '闭藏蓄势,闭关养气。' }
}
export function seasonTint(season: Season): SeasonTint {
return TINTS[season]
}
/** CSS 变量包裹:data-season 驱动整页色温(4-8% 级,金碧基调内) */
export function seasonCssVars(season: Season): Record<string, string> {
const t = TINTS[season]
return {
'--season-accent': t.accent,
'--season-glow': t.glow
}
}
export function tintLabel(season: Season): string {
return TINTS[season].name
}