diff --git a/package.json b/package.json index 9a4f29e..44a1194 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.12", + "version": "0.1.13", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", @@ -14,7 +14,8 @@ "test": "vitest run", "package": "npm run build && electron-builder", "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": { "@metona-team/metona-sqlark": "0.7.4" diff --git a/scripts/gen-art.mjs b/scripts/gen-art.mjs new file mode 100644 index 0000000..cbf1694 --- /dev/null +++ b/scripts/gen-art.mjs @@ -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 `` + } + return ` + + + + + + + + + + + + + + + + + + ${ridge(420, 46, 1.4, 0.10)} + ${ridge(500, 52, 2.1, 0.15)} + ${ridge(590, 46, 3.2, 0.22)} + + + + + + + + + +` +} + +// ============ 2. 祥云纹带(分隔/装饰) ============ +function cloudBand() { + const cloud = (x, y, s, o) => + ` + + ` + 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 `${out}` +} + +// ============ 3. 回纹角饰(卡片角框) ============ +function meanderCorner() { + const sq = (x, y, s) => `` + let p = '' + for (let i = 0; i < 4; i++) { + p += `` + } + return ` + + + + + + ` +} + +// ============ 4. 仙鹤(点缀) ============ +function crane() { + return ` + + + + + + + ` +} + +// ============ 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 += `` + } + return `${p}` +} + +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) diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 5728af8..84664a8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 && } {battleView && } + ) } diff --git a/src/renderer/assets/gen/cloud-band.svg b/src/renderer/assets/gen/cloud-band.svg new file mode 100644 index 0000000..2eafb2b --- /dev/null +++ b/src/renderer/assets/gen/cloud-band.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/gen/crane.svg b/src/renderer/assets/gen/crane.svg new file mode 100644 index 0000000..388f54d --- /dev/null +++ b/src/renderer/assets/gen/crane.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/gen/landscape-gold.svg b/src/renderer/assets/gen/landscape-gold.svg new file mode 100644 index 0000000..fd0a631 --- /dev/null +++ b/src/renderer/assets/gen/landscape-gold.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/gen/meander-corner.svg b/src/renderer/assets/gen/meander-corner.svg new file mode 100644 index 0000000..b7e7867 --- /dev/null +++ b/src/renderer/assets/gen/meander-corner.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/renderer/assets/gen/starfield.svg b/src/renderer/assets/gen/starfield.svg new file mode 100644 index 0000000..1c8c041 --- /dev/null +++ b/src/renderer/assets/gen/starfield.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/renderer/game/core/fxqueue.ts b/src/renderer/game/core/fxqueue.ts new file mode 100644 index 0000000..fe1629b --- /dev/null +++ b/src/renderer/game/core/fxqueue.ts @@ -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 + 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, next: Record): Record { + const out: Record = {} + 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 + } +} diff --git a/src/renderer/game/core/timesense.ts b/src/renderer/game/core/timesense.ts new file mode 100644 index 0000000..a2fcf0a --- /dev/null +++ b/src/renderer/game/core/timesense.ts @@ -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 = { + 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 { + const t = TINTS[season] + return { + '--season-accent': t.accent, + '--season-glow': t.glow + } +} + +export function tintLabel(season: Season): string { + return TINTS[season].name +} diff --git a/src/renderer/ui/components/DriftLayer.tsx b/src/renderer/ui/components/DriftLayer.tsx new file mode 100644 index 0000000..f87dbfe --- /dev/null +++ b/src/renderer/ui/components/DriftLayer.tsx @@ -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([]) + useEffect(() => { + const onDrift = (e: Event) => { + const detail = (e as CustomEvent).detail as Record + 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 ( +
+ {items.map((it) => ( + {it.text} + ))} +
+ ) +} diff --git a/src/renderer/ui/components/MemberCard.tsx b/src/renderer/ui/components/MemberCard.tsx index fdcee3d..ee0d015 100644 --- a/src/renderer/ui/components/MemberCard.tsx +++ b/src/renderer/ui/components/MemberCard.tsx @@ -47,9 +47,10 @@ export function MemberCard({ c }: { c: Character }) { {!dead && (
-
+
+ {c.state === 'meditation' &&
}
)}
diff --git a/src/renderer/ui/fx-canvas.ts b/src/renderer/ui/fx-canvas.ts new file mode 100644 index 0000000..d4bb074 --- /dev/null +++ b/src/renderer/ui/fx-canvas.ts @@ -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)[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) + } +} diff --git a/src/renderer/ui/fx.ts b/src/renderer/ui/fx.ts new file mode 100644 index 0000000..936453a --- /dev/null +++ b/src/renderer/ui/fx.ts @@ -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) => 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) +} diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index b884476..5adcb7e 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -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() { + + 动效档位(灵雾/金砂粒子) + + {(['soft', 'std', 'full'] as const).map((m) => ( + + ))} + + 暂停时间 diff --git a/src/renderer/ui/screens/Boot.tsx b/src/renderer/ui/screens/Boot.tsx index e40addb..37f9aae 100644 --- a/src/renderer/ui/screens/Boot.tsx +++ b/src/renderer/ui/screens/Boot.tsx @@ -15,7 +15,7 @@ export default function Boot() { const toast = useGameStore((s) => s.toast) return ( -
+
diff --git a/src/renderer/ui/screens/GameScreen.tsx b/src/renderer/ui/screens/GameScreen.tsx index b434afb..8740ae4 100644 --- a/src/renderer/ui/screens/GameScreen.tsx +++ b/src/renderer/ui/screens/GameScreen.tsx @@ -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 (
+
+ +
{fam.name}
- {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) + }) +})