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:
@@ -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 时自动停 particle(detail 在 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)
|
||||
}
|
||||
Reference in New Issue
Block a user