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:
+3
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "chronicle-of-the-immortal-clan",
|
"name": "chronicle-of-the-immortal-clan",
|
||||||
"productName": "仙途家族志",
|
"productName": "仙途家族志",
|
||||||
"version": "0.1.12",
|
"version": "0.1.13",
|
||||||
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
|
||||||
"main": "./out/main/index.js",
|
"main": "./out/main/index.js",
|
||||||
"author": "MetonaTeam",
|
"author": "MetonaTeam",
|
||||||
@@ -14,7 +14,8 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"package": "npm run build && electron-builder",
|
"package": "npm run build && electron-builder",
|
||||||
"verify": "node scripts/verify-release.mjs",
|
"verify": "node scripts/verify-release.mjs",
|
||||||
"verify:package": "node scripts/verify-release.mjs --package"
|
"verify:package": "node scripts/verify-release.mjs --package",
|
||||||
|
"gen:art": "node scripts/gen-art.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@metona-team/metona-sqlark": "0.7.4"
|
"@metona-team/metona-sqlark": "0.7.4"
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* gen-art.mjs — 程序化生成 SVG 素材(金碧山水/纹样/饰件),零外部资源。
|
||||||
|
* 用法:node scripts/gen-art.mjs (产物写 src/renderer/assets/gen/*.svg)
|
||||||
|
*/
|
||||||
|
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||||
|
import { dirname, join } from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const OUT = join(dirname(fileURLToPath(import.meta.url)), '../src/renderer/assets/gen')
|
||||||
|
mkdirSync(OUT, { recursive: true })
|
||||||
|
|
||||||
|
// ============ 1. 金碧山水全景(主背景) ============
|
||||||
|
function goldenLandscape() {
|
||||||
|
const ridge = (y, amp, seed, opacity) => {
|
||||||
|
const pts = []
|
||||||
|
let x = -40
|
||||||
|
let phase = seed
|
||||||
|
while (x <= 1280) {
|
||||||
|
const yy = y + Math.sin((x / 170) + phase) * amp + Math.sin((x / 71) + phase * 2.2) * amp * 0.3
|
||||||
|
pts.push(`${x.toFixed(0)},${yy.toFixed(0)}`)
|
||||||
|
phase += 0.35
|
||||||
|
x += 80
|
||||||
|
}
|
||||||
|
return `<polygon points="${pts.join(' ')} 1300,760 -40,760" fill="#8a6d2f" opacity="${opacity}" />`
|
||||||
|
}
|
||||||
|
return `<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)"/>
|
||||||
|
${ridge(420, 46, 1.4, 0.10)}
|
||||||
|
${ridge(500, 52, 2.1, 0.15)}
|
||||||
|
${ridge(590, 46, 3.2, 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>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 2. 祥云纹带(分隔/装饰) ============
|
||||||
|
function cloudBand() {
|
||||||
|
const cloud = (x, y, s, o) =>
|
||||||
|
`<g transform="translate(${x},${y}) scale(${s})" fill="#cbb35f" opacity="${o}">
|
||||||
|
<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>`
|
||||||
|
let out = ''
|
||||||
|
for (let i = 0; i < 6; i++) out += cloud(i * 200 - 20, ((i % 2) * 10) - 4, 0.9 + (i % 3) * 0.15, 0.5 - (i % 2) * 0.15)
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 60">${out}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 3. 回纹角饰(卡片角框) ============
|
||||||
|
function meanderCorner() {
|
||||||
|
const sq = (x, y, s) => `<path d="M${x} ${y + s} h${s} v${s} h${-s * 0} z" fill="none" stroke="#b3913e" stroke-width="2"/>`
|
||||||
|
let p = ''
|
||||||
|
for (let i = 0; i < 4; i++) {
|
||||||
|
p += `<path d="M${i * 8 + 0} 0 v${i * 8 + 0} M0 ${i * 8 + 0} h${i * 8 + 0}" />`
|
||||||
|
}
|
||||||
|
return `<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>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 4. 仙鹤(点缀) ============
|
||||||
|
function crane() {
|
||||||
|
return `<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>`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 5. 星辰点缀层 ============
|
||||||
|
function starfield() {
|
||||||
|
let p = ''
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const x = ((i * 137) % 1200)
|
||||||
|
const y = ((i * 83) % 420)
|
||||||
|
const r = 0.6 + (i % 3) * 0.5
|
||||||
|
const o = 0.12 + ((i * 7) % 5) * 0.09
|
||||||
|
p += `<circle cx="${x}" cy="${y}" r="${r}" fill="#f4dc9a" opacity="${o}"/>`
|
||||||
|
}
|
||||||
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 460">${p}</svg>`
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = {
|
||||||
|
'landscape-gold.svg': goldenLandscape(),
|
||||||
|
'cloud-band.svg': cloudBand(),
|
||||||
|
'meander-corner.svg': meanderCorner(),
|
||||||
|
'crane.svg': crane(),
|
||||||
|
'starfield.svg': starfield()
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [name, svg] of Object.entries(out)) {
|
||||||
|
writeFileSync(join(OUT, name), svg, 'utf-8')
|
||||||
|
console.log('✓', name, `${svg.length} B`)
|
||||||
|
}
|
||||||
|
console.log('gen-art done →', OUT)
|
||||||
+23
-1
@@ -7,6 +7,10 @@ import { EventModal } from './ui/components/EventModal'
|
|||||||
import { BattleModal } from './ui/components/BattleModal'
|
import { BattleModal } from './ui/components/BattleModal'
|
||||||
import { PaperModal } from './ui/components/PaperModal'
|
import { PaperModal } from './ui/components/PaperModal'
|
||||||
import { sClick } from './ui/sound'
|
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() {
|
export default function App() {
|
||||||
const screen = useGameStore((s) => s.screen)
|
const screen = useGameStore((s) => s.screen)
|
||||||
@@ -17,7 +21,24 @@ export default function App() {
|
|||||||
useGameStore.getState().init()
|
useGameStore.getState().init()
|
||||||
const clicker = () => sClick()
|
const clicker = () => sClick()
|
||||||
document.addEventListener('click', clicker, true)
|
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 (
|
return (
|
||||||
@@ -28,6 +49,7 @@ export default function App() {
|
|||||||
{pendingEventId && <EventModal />}
|
{pendingEventId && <EventModal />}
|
||||||
{battleView && <BattleModal />}
|
{battleView && <BattleModal />}
|
||||||
<PaperModal />
|
<PaperModal />
|
||||||
|
<DriftLayer />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -47,9 +47,10 @@ export function MemberCard({ c }: { c: Character }) {
|
|||||||
</div>
|
</div>
|
||||||
{!dead && (
|
{!dead && (
|
||||||
<div className="mc-foot">
|
<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 style={{ width: `${c.realmProgress}%` }} />
|
||||||
</div>
|
</div>
|
||||||
|
{c.state === 'meditation' && <div className="aura-dots"><i></i><i></i><i></i></div>}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { useGameStore } from '../store'
|
import { useGameStore } from '../store'
|
||||||
import { examplePlugin } from '../../game/engine/demo-plugins'
|
import { examplePlugin } from '../../game/engine/demo-plugins'
|
||||||
|
import { ensureFx } from '../fx'
|
||||||
import { SYSTEM_DEFS } from '../../game/engine/capabilities'
|
import { SYSTEM_DEFS } from '../../game/engine/capabilities'
|
||||||
import { buildBiography } from '../../game/core/biography'
|
import { buildBiography } from '../../game/core/biography'
|
||||||
|
|
||||||
@@ -19,6 +21,11 @@ export default function SettingsPanel() {
|
|||||||
const soundOn = useGameStore((s) => s.soundOn)
|
const soundOn = useGameStore((s) => s.soundOn)
|
||||||
const toggleSound = useGameStore((s) => s.toggleSound)
|
const toggleSound = useGameStore((s) => s.toggleSound)
|
||||||
const setNextToast = useGameStore((s) => s.setNextToast)
|
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
|
if (!world) return null
|
||||||
|
|
||||||
const hasDemo = world.pluginList().some((p) => p.id === 'demo-peaks')
|
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>
|
<button className="btn btn-sm" onClick={toggleSound}>{soundOn ? '开(点击关)' : '关(点击开)'}</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td>暂停时间</td>
|
<td>暂停时间</td>
|
||||||
<td><button className="btn btn-sm" onClick={() => setSpeed(0)}>停止流转</button></td>
|
<td><button className="btn btn-sm" onClick={() => setSpeed(0)}>停止流转</button></td>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export default function Boot() {
|
|||||||
const toast = useGameStore((s) => s.toast)
|
const toast = useGameStore((s) => s.toast)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="boot">
|
<div className="boot boot-frame">
|
||||||
<div className="boot-bg">
|
<div className="boot-bg">
|
||||||
<img src={mountainUrl} alt="" />
|
<img src={mountainUrl} alt="" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ import SettingsPanel from '../panels/SettingsPanel'
|
|||||||
import { LogFeed } from '../components/LogFeed'
|
import { LogFeed } from '../components/LogFeed'
|
||||||
import { UrgentBadges } from '../components/UrgentBadges'
|
import { UrgentBadges } from '../components/UrgentBadges'
|
||||||
import { fmt as fmtNum } from '../../game/core/format'
|
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'
|
import { GuideStrip } from '../components/GuideStrip'
|
||||||
|
|
||||||
const TABS: { id: PanelId; label: string }[] = [
|
const TABS: { id: PanelId; label: string }[] = [
|
||||||
@@ -48,12 +51,26 @@ export default function GameScreen() {
|
|||||||
const s = w.state
|
const s = w.state
|
||||||
const fam = s.family
|
const fam = s.family
|
||||||
|
|
||||||
|
const season = seasonOf(s.month)
|
||||||
|
const tint = seasonTint(season)
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
<div className="bg-scene">
|
||||||
|
<img src={landscapeUrl} alt="" />
|
||||||
|
</div>
|
||||||
<div className="topbar">
|
<div className="topbar">
|
||||||
<div className="fam-name">{fam.name}</div>
|
<div className="fam-name">{fam.name}</div>
|
||||||
<div className="date">
|
<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>
|
||||||
<div className="speed-ctl">
|
<div className="speed-ctl">
|
||||||
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} title="暂停时间">止</button>
|
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} title="暂停时间">止</button>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { metaFromState, updateSlotMeta } from './storeHelper'
|
|||||||
import { GameFacade, ActName } from '../game/engine/api'
|
import { GameFacade, ActName } from '../game/engine/api'
|
||||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
||||||
import type { PhaseStat } from '../game/core/clock'
|
import type { PhaseStat } from '../game/core/clock'
|
||||||
|
import { seasonOf } from '../game/data/season'
|
||||||
|
|
||||||
export type Screen = 'boot' | 'newgame' | 'game'
|
export type Screen = 'boot' | 'newgame' | 'game'
|
||||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
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 })
|
set({ advancing: true })
|
||||||
try {
|
try {
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
|
const prevInv = { ...w.state.family.inventory, stones: w.state.family.stones }
|
||||||
|
const prevMonth = w.state.month
|
||||||
w.advanceMonth()
|
w.advanceMonth()
|
||||||
const elapsed = performance.now() - t0
|
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) {
|
if (import.meta.env?.DEV && elapsed > 60) {
|
||||||
// 性能预算:单 tick 超 60ms 提示最慢 phase
|
// 性能预算:单 tick 超 60ms 提示最慢 phase
|
||||||
const stats = w.clock.stepMonthly(w)
|
const stats = w.clock.stepMonthly(w)
|
||||||
@@ -414,6 +433,13 @@ function makeBus(st: GameStore): WorldEventBus {
|
|||||||
else if (kind === 'bad') sBad()
|
else if (kind === 'bad') sBad()
|
||||||
else if (kind === 'war') sWar()
|
else if (kind === 'war') sWar()
|
||||||
else if (kind === 'chronicle') sBell()
|
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
|
const w = st.world
|
||||||
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
|
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -49,6 +49,134 @@ body {
|
|||||||
flex-direction: column;
|
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 ---------- */
|
/* ---------- 纸纹 overlay ---------- */
|
||||||
.paper-tex {
|
.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>");
|
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>");
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { seasonOf } from '../src/renderer/game/data/season'
|
||||||
|
import { tideOf, yearRing, seasonTint, seasonCssVars, TIDES } from '../src/renderer/game/core/timesense'
|
||||||
|
import { FxGate, FxKind } from '../src/renderer/game/core/fxqueue'
|
||||||
|
|
||||||
|
describe('timesense 时间感知矩阵', () => {
|
||||||
|
it.each([[1, 'spring'], [3, 'spring'], [4, 'summer'], [6, 'summer'], [7, 'autumn'], [9, 'autumn'], [10, 'winter'], [12, 'winter']] as const)(
|
||||||
|
'月%i → %s',
|
||||||
|
(m, s) => {
|
||||||
|
expect(seasonOf(m)).toBe(s)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each([[1, '立春'], [2, '雨水'], [4, '清明'], [12, '冬至'], [12, '小寒']])('节气 24 枚全在', () => {
|
||||||
|
expect(TIDES.length).toBe(24)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([1, 2, 4, 6, 9, 11, 12].map((m) => [m] as const))('月%i 两个节气均有效', (m) => {
|
||||||
|
expect(TIDES).toContain(tideOf(m, 0))
|
||||||
|
expect(TIDES).toContain(tideOf(m, 1))
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([1, 6, 12].map((m) => [m] as const))('年环 %i 月在 [0,1]', (m) => {
|
||||||
|
const r = yearRing(m)
|
||||||
|
expect(r).toBeGreaterThanOrEqual(0)
|
||||||
|
expect(r).toBeLessThan(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('季相色均出自金碧墨色系(accent/tint 非空且不为霓虹)', () => {
|
||||||
|
for (const s of ['spring', 'summer', 'autumn', 'winter'] as const) {
|
||||||
|
const t = seasonTint(s)
|
||||||
|
expect(t.accent).toMatch(/^#[0-9a-f]{6}$/i)
|
||||||
|
expect(t.glow).toMatch(/^\d+, \d+, \d+$/)
|
||||||
|
expect(t.name.length).toBeGreaterThan(0)
|
||||||
|
const vars = seasonCssVars(s)
|
||||||
|
expect(vars['--season-accent']).toBe(t.accent)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('FxGate 竞态安全', () => {
|
||||||
|
function freshGate() {
|
||||||
|
const emitted: FxKind[] = []
|
||||||
|
const gate = new FxGate({ emit: (e) => emitted.push(e.kind) })
|
||||||
|
return { gate, emitted }
|
||||||
|
}
|
||||||
|
|
||||||
|
it('std 模式:spark/mist/ripple/pulse 允许,blade 需 full', () => {
|
||||||
|
const { gate, emitted } = freshGate()
|
||||||
|
gate.emit('spark')
|
||||||
|
gate.emit('mist')
|
||||||
|
gate.emit('ripple')
|
||||||
|
gate.emit('pulse')
|
||||||
|
gate.emit('blade')
|
||||||
|
expect(emitted).toEqual(['spark', 'mist', 'ripple', 'pulse'])
|
||||||
|
expect(emitted).not.toContain('blade')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('soft 模式:仅 ripple/pulse/drift', () => {
|
||||||
|
const { gate, emitted } = freshGate()
|
||||||
|
gate.setMode('soft')
|
||||||
|
gate.emit('spark')
|
||||||
|
gate.emit('ripple')
|
||||||
|
gate.emit('drift')
|
||||||
|
expect(emitted).toEqual(['ripple', 'drift'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('prefers-reduced 时全发抑制(降级)', () => {
|
||||||
|
const { gate, emitted } = freshGate()
|
||||||
|
gate.setReduced(true)
|
||||||
|
gate.emit('spark')
|
||||||
|
gate.emit('pulse')
|
||||||
|
expect(emitted).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('重入安全:队列 drain 期间再 emit 不丢不叠(单帧批处理语义)', () => {
|
||||||
|
const { gate, emitted } = freshGate()
|
||||||
|
gate.emit('spark')
|
||||||
|
gate.emit('spark')
|
||||||
|
gate.emit('spark')
|
||||||
|
expect(emitted.length).toBe(3)
|
||||||
|
expect(gate.pending()).toBe(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deltas:差异、净零、NaN 滤除', () => {
|
||||||
|
const d = FxGate.deltas({ a: 10, b: 5, c: NaN }, { a: 14, b: 5, d: 3 })
|
||||||
|
expect(d.a).toBe(4)
|
||||||
|
expect(d.b).toBeUndefined()
|
||||||
|
expect(d.c).toBeUndefined() // NaN 原值被过滤
|
||||||
|
expect(d.d).toBe(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deltas 空对象不产生输出', () => {
|
||||||
|
expect(Object.keys(FxGate.deltas({}, {}))).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user