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
+95
View File
@@ -0,0 +1,95 @@
import { FxGate, FxKind } from '../game/core/fxqueue'
import { seasonOf } from '../game/data/season'
let gate: FxGate | null = null
export function fx(): FxGate | null {
return gate
}
export function ensureFx(opts?: { onEmit?: (kind: FxKind, payload?: Record<string, number | string>) => void }): FxGate {
if (gate) return gate
gate = new FxGate({
emit: (e) => {
switch (e.kind) {
case 'mist': {
window.dispatchEvent(new CustomEvent('fx:mist'))
break
}
case 'spark': {
window.dispatchEvent(new CustomEvent('fx:spark'))
break
}
case 'ripple': {
window.dispatchEvent(new CustomEvent('fx:ripple', { detail: e.payload ?? {} }))
break
}
case 'blade': {
window.dispatchEvent(new CustomEvent('fx:blade'))
break
}
case 'seasonShift': {
window.dispatchEvent(new CustomEvent('fx:season', { detail: e.payload ?? {} }))
break
}
case 'drift': {
window.dispatchEvent(new CustomEvent('fx:drift', { detail: e.payload ?? {} }))
break
}
case 'pulse': {
window.dispatchEvent(new CustomEvent('fx:pulse', { detail: e.payload ?? {} }))
break
}
}
opts?.onEmit?.(e.kind, e.payload)
}
})
if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
gate.setReduced(mq.matches)
mq.addEventListener('change', (ev) => gate?.setReduced(ev.matches))
}
return gate
}
/** store.advance 后调用:检测季节变化 → 季节脉动 + 季节色温 CSS 变量 */
export function fxSeason(prevMonth: number, nextMonth: number): void {
const g = ensureFx()
const prev = seasonOf(prevMonth)
const next = seasonOf(nextMonth)
if (prev !== next) {
g.emit('seasonShift', { season: next, accent: labelOf(next) })
}
applySeason(next)
}
function labelOf(s: string): string {
return { spring: '春', summer: '夏', autumn: '秋', winter: '冬' }[s] ?? s
}
export function seasonTintOf(month: number): string {
const s = seasonOf(month)
return labelOf(s)
}
/** 直接应用季节色温 CSS 变量(单源 data-season */
export function applySeason(season: string): void {
if (typeof document === 'undefined') return
document.documentElement.setAttribute('data-season', season)
}
/** 慢帧保险:document.hidden 时自动停 particledetail 在 canvas 端监听) */
export function fxVisibilityAutopause(): () => void {
const onVis = () => {
const g = ensureFx()
if (document.hidden) {
// hidden 时禁止未来发射
g.setReduced(true)
} else {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
g.setReduced(mq.matches)
}
}
document.addEventListener('visibilitychange', onVis)
return () => document.removeEventListener('visibilitychange', onVis)
}