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
+23 -1
View File
@@ -7,6 +7,10 @@ import { EventModal } from './ui/components/EventModal'
import { BattleModal } from './ui/components/BattleModal'
import { PaperModal } from './ui/components/PaperModal'
import { sClick } from './ui/sound'
import { ensureFx, fxVisibilityAutopause, applySeason } from './ui/fx'
import { startFxLayer, spawnBurst, stopFxLayer } from './ui/fx-canvas'
import { DriftLayer } from './ui/components/DriftLayer'
import { seasonOf } from './game/data/season'
export default function App() {
const screen = useGameStore((s) => s.screen)
@@ -17,7 +21,24 @@ export default function App() {
useGameStore.getState().init()
const clicker = () => sClick()
document.addEventListener('click', clicker, true)
return () => document.removeEventListener('click', clicker, true)
const fx = ensureFx()
const stopVis = fxVisibilityAutopause()
const burst = (e: Event) => {
const kind = e.type.replace('fx:', '')
if (kind === 'spark' || kind === 'blade') spawnBurst('spark', 'center')
if (kind === 'ripple') spawnBurst('ripple')
if (kind === 'pulse') spawnBurst('ripple')
}
for (const k of ['fx:spark', 'fx:blade', 'fx:ripple', 'fx:pulse']) window.addEventListener(k, burst)
const seasonInit = () => applySeason(seasonOf(useGameStore.getState().world?.state.month ?? 1))
seasonInit()
startFxLayer(fx.mode === 'soft' ? 18 : fx.mode === 'full' ? 70 : 36)
return () => {
document.removeEventListener('click', clicker, true)
stopVis()
for (const k of ['fx:spark', 'fx:blade', 'fx:ripple', 'fx:pulse']) window.removeEventListener(k, burst)
stopFxLayer()
}
}, [])
return (
@@ -28,6 +49,7 @@ export default function App() {
{pendingEventId && <EventModal />}
{battleView && <BattleModal />}
<PaperModal />
<DriftLayer />
</div>
)
}
+13
View File
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 60"><g transform="translate(-20,-4) scale(0.9)" fill="#cbb35f" opacity="0.5">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g><g transform="translate(180,6) scale(1.05)" fill="#cbb35f" opacity="0.35">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g><g transform="translate(380,-4) scale(1.2)" fill="#cbb35f" opacity="0.5">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g><g transform="translate(580,6) scale(0.9)" fill="#cbb35f" opacity="0.35">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g><g transform="translate(780,-4) scale(1.05)" fill="#cbb35f" opacity="0.5">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g><g transform="translate(980,6) scale(1.2)" fill="#cbb35f" opacity="0.35">
<path d="M0 0 q14 -20 34 -18 q26 2 30 14 q18 -4 26 8 q4 12 -8 16 H8 q-14 -2 -8 -20z"/>
</g></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 60">
<g stroke="#e0cf9b" stroke-width="2.2" fill="none" opacity="0.7" stroke-linecap="round">
<path d="M8 52 q22 -18 42 -16"/>
<path d="M50 36 q14 12 30 14"/>
<path d="M44 34 L22 22 M44 34 L70 20 M44 34 L52 14"/>
</g>
<circle cx="42" cy="33" r="2.4" fill="#e6b84c" opacity="0.9"/>
</svg>

After

Width:  |  Height:  |  Size: 376 B

@@ -0,0 +1,31 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 760" preserveAspectRatio="xMidYMax slice">
<defs>
<linearGradient id="sky" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#181208"/>
<stop offset="0.65" stop-color="#241a10"/>
<stop offset="1" stop-color="#38271a"/>
</linearGradient>
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#f2cf7e" stop-opacity="0.9"/>
<stop offset="0.5" stop-color="#c99a3d" stop-opacity="0.35"/>
<stop offset="1" stop-color="#c99a3d" stop-opacity="0"/>
</radialGradient>
<filter id="blur2"><feGaussianBlur stdDeviation="2"/></filter>
<filter id="blur6"><feGaussianBlur stdDeviation="6"/></filter>
</defs>
<rect width="1200" height="760" fill="url(#sky)"/>
<circle cx="905" cy="150" r="130" fill="url(#sun)"/>
<circle cx="905" cy="150" r="30" fill="#f6dd9d" opacity="0.75" filter="url(#blur2)"/>
<polygon points="-40,470 40,449 120,436 200,412 280,367 360,374 440,424 520,446 600,453 680,465 760,429 840,375 920,376 1000,401 1080,414 1160,450 1240,479 1300,760 -40,760" fill="#8a6d2f" opacity="0.1" />
<polygon points="-40,537 40,518 120,497 200,447 280,440 360,497 440,529 520,535 600,549 680,521 760,455 840,445 920,476 1000,492 1080,526 1160,566 1240,536 1300,760 -40,760" fill="#8a6d2f" opacity="0.15" />
<polygon points="-40,601 40,574 120,534 200,550 280,602 360,619 440,624 520,631 600,590 680,539 760,549 840,576 920,590 1000,626 1080,648 1160,604 1240,562 1300,760 -40,760" fill="#8a6d2f" opacity="0.22" />
<g stroke="#cbb35f" stroke-width="1.6" opacity="0.35" fill="none" filter="url(#blur2)">
<path d="M300 262 q16 -11 32 0 q16 -11 32 0"/>
<path d="M250 288 q14 -10 28 0 q14 -10 28 0"/>
<path d="M980 205 q13 -9 26 0 q13 -9 26 0"/>
</g>
<g stroke="#cbb35f" stroke-width="0.9" opacity="0.22">
<path d="M60 620 h300 M60 640 h220 M60 660 h150" />
</g>
<rect x="0" y="700" width="1200" height="60" fill="#1c1409" opacity="0.4"/>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40">
<g fill="none" stroke="#b3913e" stroke-width="1.6" opacity="0.7">
<path d="M4 36 L4 4 L36 4" />
<path d="M10 30 L10 10 L34 10" />
<path d="M16 24 L16 16 L30 16" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 264 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 460"><circle cx="0" cy="0" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="137" cy="83" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="274" cy="166" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="411" cy="249" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="548" cy="332" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="685" cy="415" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="822" cy="78" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="959" cy="161" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="1096" cy="244" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="33" cy="327" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="170" cy="410" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="307" cy="73" r="1.6" fill="#f4dc9a" opacity="0.3"/><circle cx="444" cy="156" r="0.6" fill="#f4dc9a" opacity="0.48"/><circle cx="581" cy="239" r="1.1" fill="#f4dc9a" opacity="0.21"/><circle cx="718" cy="322" r="1.6" fill="#f4dc9a" opacity="0.39"/><circle cx="855" cy="405" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="992" cy="68" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="1129" cy="151" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="66" cy="234" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="203" cy="317" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="340" cy="400" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="477" cy="63" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="614" cy="146" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="751" cy="229" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="888" cy="312" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="1025" cy="395" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="1162" cy="58" r="1.6" fill="#f4dc9a" opacity="0.3"/><circle cx="99" cy="141" r="0.6" fill="#f4dc9a" opacity="0.48"/><circle cx="236" cy="224" r="1.1" fill="#f4dc9a" opacity="0.21"/><circle cx="373" cy="307" r="1.6" fill="#f4dc9a" opacity="0.39"/><circle cx="510" cy="390" r="0.6" fill="#f4dc9a" opacity="0.12"/><circle cx="647" cy="53" r="1.1" fill="#f4dc9a" opacity="0.3"/><circle cx="784" cy="136" r="1.6" fill="#f4dc9a" opacity="0.48"/><circle cx="921" cy="219" r="0.6" fill="#f4dc9a" opacity="0.21"/><circle cx="1058" cy="302" r="1.1" fill="#f4dc9a" opacity="0.39"/><circle cx="1195" cy="385" r="1.6" fill="#f4dc9a" opacity="0.12"/><circle cx="132" cy="48" r="0.6" fill="#f4dc9a" opacity="0.3"/><circle cx="269" cy="131" r="1.1" fill="#f4dc9a" opacity="0.48"/><circle cx="406" cy="214" r="1.6" fill="#f4dc9a" opacity="0.21"/><circle cx="543" cy="297" r="0.6" fill="#f4dc9a" opacity="0.39"/><circle cx="680" cy="380" r="1.1" fill="#f4dc9a" opacity="0.12"/><circle cx="817" cy="43" r="1.6" fill="#f4dc9a" opacity="0.3"/></svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

+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
}
+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>");