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
+34
View File
@@ -0,0 +1,34 @@
import { useEffect, useState } from 'react'
interface DriftItem { id: number; text: string; cls: string }
/** 资源漂字层:每次 fx:drift 事件追加独立节点(互不覆盖),超 8 条自动修剪尾部 */
export function DriftLayer() {
const [items, setItems] = useState<DriftItem[]>([])
useEffect(() => {
const onDrift = (e: Event) => {
const detail = (e as CustomEvent).detail as Record<string, number>
const now = Date.now()
const next: DriftItem[] = []
for (const [k, v] of Object.entries(detail)) {
if (v === 0 || !Number.isFinite(v)) continue
next.push({ id: now + Math.random() + k.length, text: `${v > 0 ? '+' : ''}${v}`, cls: v > 0 ? 'pos' : 'neg' })
}
if (next.length === 0) return
setItems((old) => [...old, ...next].slice(-8))
}
window.addEventListener('fx:drift', onDrift)
const timer = setInterval(() => setItems((old) => old.slice(0)), 2600) // 到期清理由 CSS fade 承担
return () => {
window.removeEventListener('fx:drift', onDrift)
clearInterval(timer)
}
}, [])
return (
<div className="drift-layer">
{items.map((it) => (
<span key={it.id} className={`drift ${it.cls}`}>{it.text}</span>
))}
</div>
)
}
+2 -1
View File
@@ -47,9 +47,10 @@ export function MemberCard({ c }: { c: Character }) {
</div>
{!dead && (
<div className="mc-foot">
<div className="bar" title="灵气修为" style={{ height: 6 }}>
<div className={`bar ${c.state === 'meditation' ? 'shimmer' : ''}`} title="灵气修为" style={{ height: 6 }}>
<div style={{ width: `${c.realmProgress}%` }} />
</div>
{c.state === 'meditation' && <div className="aura-dots"><i></i><i></i><i></i></div>}
</div>
)}
</div>
+150
View File
@@ -0,0 +1,150 @@
/** Canvas 粒子渲染层(单 RAF 实例,GPU 友好) */
import { seasonOf } from '../game/data/season'
interface Particle {
x: number
y: number
vx: number
vy: number
life: number
maxLife: number
size: number
rot: number
vr: number
kind: 'gold' | 'mist' | 'light'
color: string
}
let canvas: HTMLCanvasElement | null = null
let ctx: CanvasRenderingContext2D | null = null
let raf = 0
let running = false
let particles: Particle[] = []
let density = 40
let mistTimer = 0
function ensureCanvas(): HTMLCanvasElement | null {
if (canvas && canvas.isConnected) return canvas
const parent = document.body
if (!parent) return null
canvas = document.createElement('canvas')
canvas.id = 'fx-layer'
Object.assign(canvas.style, {
position: 'fixed',
inset: '0',
width: '100%',
height: '100%',
pointerEvents: 'none',
zIndex: '45',
opacity: '0.9'
})
parent.appendChild(canvas)
ctx = canvas.getContext('2d')
resize()
return canvas
}
function resize(): void {
if (!canvas) return
const dpr = Math.min(2, window.devicePixelRatio || 1)
canvas.width = window.innerWidth * dpr
canvas.height = window.innerHeight * dpr
ctx?.setTransform(dpr, 0, 0, dpr, 0, 0)
}
function seasonColor(): string {
const s = seasonOf(new Date().getMonth() + 1) as 'spring' | 'summer' | 'autumn' | 'winter'
return ({ spring: '154,180,130', summer: '224,189,84', autumn: '224,148,74', winter: '138,167,189' } as Record<string, string>)[s] ?? '216,182,120'
}
function spawn(kind: 'gold' | 'mist' | 'light', x?: number, y?: number): void {
if (!canvas) return
const W = window.innerWidth
const H = window.innerHeight
const c = seasonColor()
const p: Particle = {
x: x ?? Math.random() * W,
y: y ?? Math.random() * H,
vx: (Math.random() - 0.5) * 0.5,
vy: -0.25 - Math.random() * 0.6,
life: 0,
maxLife: kind === 'gold' ? 120 : kind === 'light' ? 50 : 200,
size: kind === 'gold' ? 2 + Math.random() * 2 : kind === 'mist' ? 10 + Math.random() * 24 : 3,
rot: Math.random() * Math.PI * 2,
vr: (Math.random() - 0.5) * 0.06,
kind,
color: kind === 'gold' ? `rgba(236, 199, 118,` : kind === 'light' ? `rgba(250, 233, 185,` : `rgba(${c},`
}
particles.push(p)
if (particles.length > density * 6) particles.splice(0, particles.length - density * 6) // 竞态防胀
}
function frame(): void {
if (!running) return
ctx?.clearRect(0, 0, window.innerWidth, window.innerHeight)
const dt = 1
mistTimer += 1
if (mistTimer % 18 === 0 && particles.length < density) spawn('mist')
for (let i = particles.length - 1; i >= 0; i--) {
const p = particles[i]
p.life += dt
p.x += p.vx
p.y += p.vy
p.rot += p.vr
if (p.life >= p.maxLife || p.x < -60 || p.x > window.innerWidth + 60 || p.y < -60) {
particles.splice(i, 1)
continue
}
if (!ctx) continue
const alpha = Math.max(0, (1 - p.life / p.maxLife)) * (p.kind === 'mist' ? 0.22 : 0.85)
ctx.save()
ctx.translate(p.x, p.y)
ctx.rotate(p.rot)
ctx.fillStyle = `${p.color}${alpha})`
if (p.kind === 'mist') {
ctx.beginPath()
ctx.ellipse(0, 0, p.size, p.size * 0.5, 0, 0, Math.PI * 2)
ctx.fill()
} else {
ctx.beginPath()
ctx.moveTo(0, -p.size)
ctx.lineTo(p.size * 0.6, 0)
ctx.lineTo(0, p.size)
ctx.lineTo(-p.size * 0.6, 0)
ctx.closePath()
ctx.fill()
}
ctx.restore()
}
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
}
export function startFxLayer(modeDensity: number): void {
const c = ensureCanvas()
if (!c || running) return // 单循环防重入
density = modeDensity
running = true
window.addEventListener('resize', resize)
spawnBurst('spark', 'center')
raf = window.requestAnimationFrame(frame as FrameRequestCallback)
}
export function stopFxLayer(): void {
running = false
if (raf) cancelAnimationFrame(raf)
raf = 0
particles = []
}
export function spawnBurst(kind: 'spark' | 'blade' | 'ripple', origin?: string): void {
if (!running) return
if (kind === 'spark') {
const X = window.innerWidth / 2
const Y = window.innerHeight * (origin === 'center' ? 0.5 : 0.3)
for (let i = 0; i < 26; i++) spawn('gold', X + (Math.random() - 0.5) * 160, Y + (Math.random() - 0.5) * 60)
} else if (kind === 'blade') {
for (let i = 0; i < 14; i++) spawn('light', window.innerWidth * (0.2 + Math.random() * 0.6), window.innerHeight * 0.4)
} else if (kind === 'ripple') {
for (let i = 0; i < 10; i++) spawn('gold', window.innerWidth * 0.5, window.innerHeight * 0.06)
}
}
+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)
}
+21
View File
@@ -1,5 +1,7 @@
import { useState } from 'react'
import { useGameStore } from '../store'
import { examplePlugin } from '../../game/engine/demo-plugins'
import { ensureFx } from '../fx'
import { SYSTEM_DEFS } from '../../game/engine/capabilities'
import { buildBiography } from '../../game/core/biography'
@@ -19,6 +21,11 @@ export default function SettingsPanel() {
const soundOn = useGameStore((s) => s.soundOn)
const toggleSound = useGameStore((s) => s.toggleSound)
const setNextToast = useGameStore((s) => s.setNextToast)
const [fxMode, setFxModeState] = useState('std')
const setFxMode = (m: 'soft' | 'std' | 'full') => {
setFxModeState(m)
ensureFx().setMode(m)
}
if (!world) return null
const hasDemo = world.pluginList().some((p) => p.id === 'demo-peaks')
@@ -156,6 +163,20 @@ export default function SettingsPanel() {
<button className="btn btn-sm" onClick={toggleSound}>{soundOn ? '开(点击关)' : '关(点击开)'}</button>
</td>
</tr>
<tr>
<td>/</td>
<td>
{(['soft', 'std', 'full'] as const).map((m) => (
<button
key={m}
className={`btn btn-sm ${fxMode === m ? 'btn-primary' : ''}`}
onClick={() => { setFxMode(m); bump() }}
>
{m === 'soft' ? '柔和' : m === 'std' ? '标准' : '全开'}
</button>
))}
</td>
</tr>
<tr>
<td></td>
<td><button className="btn btn-sm" onClick={() => setSpeed(0)}></button></td>
+1 -1
View File
@@ -15,7 +15,7 @@ export default function Boot() {
const toast = useGameStore((s) => s.toast)
return (
<div className="boot">
<div className="boot boot-frame">
<div className="boot-bg">
<img src={mountainUrl} alt="" />
</div>
+18 -1
View File
@@ -13,6 +13,9 @@ import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed'
import { UrgentBadges } from '../components/UrgentBadges'
import { fmt as fmtNum } from '../../game/core/format'
import { seasonOf } from '../../game/data/season'
import { seasonTint, yearRing, tideOf } from '../../game/core/timesense'
import landscapeUrl from '../../assets/gen/landscape-gold.svg'
import { GuideStrip } from '../components/GuideStrip'
const TABS: { id: PanelId; label: string }[] = [
@@ -48,12 +51,26 @@ export default function GameScreen() {
const s = w.state
const fam = s.family
const season = seasonOf(s.month)
const tint = seasonTint(season)
return (
<div className="app">
<div className="bg-scene">
<img src={landscapeUrl} alt="" />
</div>
<div className="topbar">
<div className="fam-name">{fam.name}</div>
<div className="date">
{s.year}{s.month} · {fam.generation} · {fam.reputation}
<span className="date-season" style={{ color: tint.accent }} title={`${tint.name} · ${tint.desc}`}>{tint.name}</span>
{' '}{s.year}{s.month} · {fam.generation} · {fam.reputation}
<span className="year-ring">
<svg viewBox="0 0 24 24">
<circle cx="12" cy="12" r="10" fill="none" stroke="rgba(179,145,62,0.25)" strokeWidth="2.4" />
<circle cx="12" cy="12" r="10" fill="none" stroke="#c9a227" strokeWidth="2.4" strokeDasharray="62.8"
strokeDashoffset={`${62.8 * (1 - yearRing(s.month as number))}`} transform="rotate(-90 12 12)" strokeLinecap="round" />
</svg>
<span className="ring-month">{s.month}</span>
</span>
</div>
<div className="speed-ctl">
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} title="暂停时间"></button>
+26
View File
@@ -8,6 +8,7 @@ import { metaFromState, updateSlotMeta } from './storeHelper'
import { GameFacade, ActName } from '../game/engine/api'
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
import type { PhaseStat } from '../game/core/clock'
import { seasonOf } from '../game/data/season'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
@@ -186,8 +187,26 @@ export const useGameStore = create<GameStore>((set, get) => ({
set({ advancing: true })
try {
const t0 = performance.now()
const prevInv = { ...w.state.family.inventory, stones: w.state.family.stones }
const prevMonth = w.state.month
w.advanceMonth()
const elapsed = performance.now() - t0
try {
const nextInv = { ...w.state.family.inventory, stones: w.state.family.stones }
const FxGate = (await import('../game/core/fxqueue')).FxGate
const deltas = FxGate.deltas(prevInv, nextInv)
const windowRef = window as unknown as { dispatchEvent: (e: Event) => void }
windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: deltas }))
const seasonNow = seasonOf(w.state.month)
const seasonPrev = seasonOf(prevMonth)
if (seasonNow !== seasonPrev) {
const fxmod = await import('./fx')
fxmod.ensureFx().emit('seasonShift', { season: seasonNow })
fxmod.applySeason(seasonNow)
}
} catch {
// fx 为增强项,失败不阻塞推进
}
if (import.meta.env?.DEV && elapsed > 60) {
// 性能预算:单 tick 超 60ms 提示最慢 phase
const stats = w.clock.stepMonthly(w)
@@ -414,6 +433,13 @@ function makeBus(st: GameStore): WorldEventBus {
else if (kind === 'bad') sBad()
else if (kind === 'war') sWar()
else if (kind === 'chronicle') sBell()
// 动效联动:突破/获宝→金砂,战事→刀光
if (kind === 'good' && /突破|获得|购得|落槌|渡劫功成/.test(text)) {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('spark'))
}
if (kind === 'war') {
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('blade'))
}
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
+128
View File
@@ -49,6 +49,134 @@ body {
flex-direction: column;
}
/* ---------- 金碧山河背景层(随季节色温) ---------- */
.bg-scene {
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
opacity: 0.5;
transition: opacity 1.2s ease, filter 2.5s ease;
}
.bg-scene img {
width: 100%;
height: 100%;
object-fit: cover;
filter: brightness(0.9);
}
html[data-season='spring'] .bg-scene { filter: hue-rotate(8deg) brightness(0.96); }
html[data-season='summer'] .bg-scene { filter: brightness(1.04) saturate(1.1); }
html[data-season='autumn'] .bg-scene { filter: hue-rotate(-10deg) saturate(1.16); }
html[data-season='winter'] .bg-scene { filter: hue-rotate(20deg) brightness(0.92) saturate(0.86); }
.main-left, .main-right { position: relative; z-index: 1; }
.main-left { background: transparent; }
.main-right { background: rgba(13, 10, 6, 0.88); backdrop-filter: blur(2px); }
.topbar, .tabs { position: relative; z-index: 2; }
/* ---------- 顶栏季节印与年轮环 ---------- */
.date-season {
font-weight: 700;
letter-spacing: 2px;
margin-right: 4px;
}
.year-ring {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 12px;
vertical-align: middle;
}
.year-ring svg { width: 26px; height: 26px; transform: rotate(0deg); }
.ring-month {
position: absolute;
font-size: 0.66rem;
color: #d8c392;
line-height: 1;
}
/* ---------- 成员卡:闭关 aura + 进度流光 ---------- */
.aura-dots { position: relative; height: 10px; margin-top: 3px; }
.aura-dots i {
position: absolute;
width: 6px; height: 6px;
border-radius: 50%;
background: rgba(216, 195, 146, 0.85);
animation: aurafloat 2.4s ease-in-out infinite;
}
.aura-dots i:nth-child(1) { left: 20%; animation-delay: 0s; }
.aura-dots i:nth-child(2) { left: 50%; top: -3px; animation-delay: 0.8s; opacity: 0.6; }
.aura-dots i:nth-child(3) { left: 76%; animation-delay: 1.6s; }
@keyframes aurafloat {
0%, 100% { transform: translateY(0); opacity: 0.85; }
50% { transform: translateY(-4px); opacity: 0.3; }
}
.bar.shimmer > div {
background-size: 200% 100%;
animation: shimmer 2.2s linear infinite;
}
@keyframes shimmer {
from { background-position: 200% 0; }
to { background-position: -200% 0; }
}
/* ---------- 漂字层(独立节点,互不覆盖) ---------- */
.drift-layer {
position: fixed;
top: 74px;
left: 50%;
transform: translateX(-50%);
z-index: 60;
pointer-events: none;
display: flex;
gap: 8px;
flex-direction: row-reverse;
}
.drift {
animation: drift-up 2.5s ease-out forwards;
font-size: 0.82rem;
background: rgba(20, 14, 8, 0.75);
padding: 2px 8px;
border-radius: 2px;
border: 1px solid rgba(150, 120, 60, 0.4);
}
.drift.pos { color: #b6d49a; }
.drift.neg { color: #df9a8a; }
@keyframes drift-up {
0% { opacity: 0; transform: translateY(8px); }
10% { opacity: 1; }
70% { opacity: 1; }
100% { opacity: 0; transform: translateY(-22px); }
}
/* ---------- 战报刀光 ---------- */
html[data-blade] .battle-lines {
animation: blade-sweep 0.9s ease-out;
}
@keyframes blade-sweep {
0% { filter: brightness(1); }
30% { filter: brightness(1.6) saturate(1.2); }
100% { filter: brightness(1); }
}
/* ---------- 卷轴展开(Boot 过渡) ---------- */
.boot-frame { animation: scroll-open 0.8s ease-out; }
@keyframes scroll-open {
from { opacity: 0; transform: translateY(10px) scaleY(0.6); }
to { opacity: 1; transform: none; }
}
/* ---------- 金角线卡片 ---------- */
.gilded::before {
content: '';
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 4px;
background:
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><g fill="none" stroke="%23b3913e" stroke-width="1.6" opacity="0.5"><path d="M4 36 L4 4 L36 4"/><path d="M10 30 L10 10 L30 10"/><path d="M16 24 L16 16 L24 16"/></g></svg>') top left / 40px 40px no-repeat;
}
/* ---------- 纸纹 overlay ---------- */
.paper-tex {
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='7'/><feColorMatrix type='saturate' values='0'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.55'/></svg>");