- {s.year}年{s.month}月 · 第{fam.generation}代 · 声望{fam.reputation}
+ {tint.name}
+ {' '}{s.year}年{s.month}月 · 第{fam.generation}代 · 声望{fam.reputation}
+
+
+ {s.month}
+
diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts
index 2827efc..2aca894 100644
--- a/src/renderer/ui/store.ts
+++ b/src/renderer/ui/store.ts
@@ -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
((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 })
},
diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css
index 74982c8..5c3ad6f 100644
--- a/src/renderer/ui/styles.css
+++ b/src/renderer/ui/styles.css
@@ -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,') top left / 40px 40px no-repeat;
+}
+
/* ---------- 纸纹 overlay ---------- */
.paper-tex {
background-image: url("data:image/svg+xml;utf8,");
diff --git a/tests/fx-timesense.test.ts b/tests/fx-timesense.test.ts
new file mode 100644
index 0000000..cdc9c1b
--- /dev/null
+++ b/tests/fx-timesense.test.ts
@@ -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)
+ })
+})