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:
@@ -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>
|
||||
{!dead && (
|
||||
<div className="mc-foot">
|
||||
<div className="bar" title="灵气修为" style={{ height: 6 }}>
|
||||
<div className={`bar ${c.state === 'meditation' ? 'shimmer' : ''}`} title="灵气修为" style={{ height: 6 }}>
|
||||
<div style={{ width: `${c.realmProgress}%` }} />
|
||||
</div>
|
||||
{c.state === 'meditation' && <div className="aura-dots"><i></i><i></i><i></i></div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -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 { examplePlugin } from '../../game/engine/demo-plugins'
|
||||
import { ensureFx } from '../fx'
|
||||
import { SYSTEM_DEFS } from '../../game/engine/capabilities'
|
||||
import { buildBiography } from '../../game/core/biography'
|
||||
|
||||
@@ -19,6 +21,11 @@ export default function SettingsPanel() {
|
||||
const soundOn = useGameStore((s) => s.soundOn)
|
||||
const toggleSound = useGameStore((s) => s.toggleSound)
|
||||
const setNextToast = useGameStore((s) => s.setNextToast)
|
||||
const [fxMode, setFxModeState] = useState('std')
|
||||
const setFxMode = (m: 'soft' | 'std' | 'full') => {
|
||||
setFxModeState(m)
|
||||
ensureFx().setMode(m)
|
||||
}
|
||||
if (!world) return null
|
||||
|
||||
const hasDemo = world.pluginList().some((p) => p.id === 'demo-peaks')
|
||||
@@ -156,6 +163,20 @@ export default function SettingsPanel() {
|
||||
<button className="btn btn-sm" onClick={toggleSound}>{soundOn ? '开(点击关)' : '关(点击开)'}</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>动效档位(灵雾/金砂粒子)</td>
|
||||
<td>
|
||||
{(['soft', 'std', 'full'] as const).map((m) => (
|
||||
<button
|
||||
key={m}
|
||||
className={`btn btn-sm ${fxMode === m ? 'btn-primary' : ''}`}
|
||||
onClick={() => { setFxMode(m); bump() }}
|
||||
>
|
||||
{m === 'soft' ? '柔和' : m === 'std' ? '标准' : '全开'}
|
||||
</button>
|
||||
))}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>暂停时间</td>
|
||||
<td><button className="btn btn-sm" onClick={() => setSpeed(0)}>停止流转</button></td>
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Boot() {
|
||||
const toast = useGameStore((s) => s.toast)
|
||||
|
||||
return (
|
||||
<div className="boot">
|
||||
<div className="boot boot-frame">
|
||||
<div className="boot-bg">
|
||||
<img src={mountainUrl} alt="" />
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,9 @@ import SettingsPanel from '../panels/SettingsPanel'
|
||||
import { LogFeed } from '../components/LogFeed'
|
||||
import { UrgentBadges } from '../components/UrgentBadges'
|
||||
import { fmt as fmtNum } from '../../game/core/format'
|
||||
import { seasonOf } from '../../game/data/season'
|
||||
import { seasonTint, yearRing, tideOf } from '../../game/core/timesense'
|
||||
import landscapeUrl from '../../assets/gen/landscape-gold.svg'
|
||||
import { GuideStrip } from '../components/GuideStrip'
|
||||
|
||||
const TABS: { id: PanelId; label: string }[] = [
|
||||
@@ -48,12 +51,26 @@ export default function GameScreen() {
|
||||
const s = w.state
|
||||
const fam = s.family
|
||||
|
||||
const season = seasonOf(s.month)
|
||||
const tint = seasonTint(season)
|
||||
return (
|
||||
<div className="app">
|
||||
<div className="bg-scene">
|
||||
<img src={landscapeUrl} alt="" />
|
||||
</div>
|
||||
<div className="topbar">
|
||||
<div className="fam-name">{fam.name}</div>
|
||||
<div className="date">
|
||||
{s.year}年{s.month}月 · 第{fam.generation}代 · 声望{fam.reputation}
|
||||
<span className="date-season" style={{ color: tint.accent }} title={`${tint.name} · ${tint.desc}`}>{tint.name}</span>
|
||||
{' '}{s.year}年{s.month}月 · 第{fam.generation}代 · 声望{fam.reputation}
|
||||
<span className="year-ring">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="rgba(179,145,62,0.25)" strokeWidth="2.4" />
|
||||
<circle cx="12" cy="12" r="10" fill="none" stroke="#c9a227" strokeWidth="2.4" strokeDasharray="62.8"
|
||||
strokeDashoffset={`${62.8 * (1 - yearRing(s.month as number))}`} transform="rotate(-90 12 12)" strokeLinecap="round" />
|
||||
</svg>
|
||||
<span className="ring-month">{s.month}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="speed-ctl">
|
||||
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} title="暂停时间">止</button>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { metaFromState, updateSlotMeta } from './storeHelper'
|
||||
import { GameFacade, ActName } from '../game/engine/api'
|
||||
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
|
||||
import type { PhaseStat } from '../game/core/clock'
|
||||
import { seasonOf } from '../game/data/season'
|
||||
|
||||
export type Screen = 'boot' | 'newgame' | 'game'
|
||||
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'legacy' | 'settings'
|
||||
@@ -186,8 +187,26 @@ export const useGameStore = create<GameStore>((set, get) => ({
|
||||
set({ advancing: true })
|
||||
try {
|
||||
const t0 = performance.now()
|
||||
const prevInv = { ...w.state.family.inventory, stones: w.state.family.stones }
|
||||
const prevMonth = w.state.month
|
||||
w.advanceMonth()
|
||||
const elapsed = performance.now() - t0
|
||||
try {
|
||||
const nextInv = { ...w.state.family.inventory, stones: w.state.family.stones }
|
||||
const FxGate = (await import('../game/core/fxqueue')).FxGate
|
||||
const deltas = FxGate.deltas(prevInv, nextInv)
|
||||
const windowRef = window as unknown as { dispatchEvent: (e: Event) => void }
|
||||
windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: deltas }))
|
||||
const seasonNow = seasonOf(w.state.month)
|
||||
const seasonPrev = seasonOf(prevMonth)
|
||||
if (seasonNow !== seasonPrev) {
|
||||
const fxmod = await import('./fx')
|
||||
fxmod.ensureFx().emit('seasonShift', { season: seasonNow })
|
||||
fxmod.applySeason(seasonNow)
|
||||
}
|
||||
} catch {
|
||||
// fx 为增强项,失败不阻塞推进
|
||||
}
|
||||
if (import.meta.env?.DEV && elapsed > 60) {
|
||||
// 性能预算:单 tick 超 60ms 提示最慢 phase
|
||||
const stats = w.clock.stepMonthly(w)
|
||||
@@ -414,6 +433,13 @@ function makeBus(st: GameStore): WorldEventBus {
|
||||
else if (kind === 'bad') sBad()
|
||||
else if (kind === 'war') sWar()
|
||||
else if (kind === 'chronicle') sBell()
|
||||
// 动效联动:突破/获宝→金砂,战事→刀光
|
||||
if (kind === 'good' && /突破|获得|购得|落槌|渡劫功成/.test(text)) {
|
||||
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('spark'))
|
||||
}
|
||||
if (kind === 'war') {
|
||||
void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit('blade'))
|
||||
}
|
||||
const w = st.world
|
||||
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
|
||||
},
|
||||
|
||||
@@ -49,6 +49,134 @@ body {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ---------- 金碧山河背景层(随季节色温) ---------- */
|
||||
.bg-scene {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 0;
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
transition: opacity 1.2s ease, filter 2.5s ease;
|
||||
}
|
||||
.bg-scene img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
html[data-season='spring'] .bg-scene { filter: hue-rotate(8deg) brightness(0.96); }
|
||||
html[data-season='summer'] .bg-scene { filter: brightness(1.04) saturate(1.1); }
|
||||
html[data-season='autumn'] .bg-scene { filter: hue-rotate(-10deg) saturate(1.16); }
|
||||
html[data-season='winter'] .bg-scene { filter: hue-rotate(20deg) brightness(0.92) saturate(0.86); }
|
||||
.main-left, .main-right { position: relative; z-index: 1; }
|
||||
.main-left { background: transparent; }
|
||||
.main-right { background: rgba(13, 10, 6, 0.88); backdrop-filter: blur(2px); }
|
||||
.topbar, .tabs { position: relative; z-index: 2; }
|
||||
|
||||
/* ---------- 顶栏季节印与年轮环 ---------- */
|
||||
.date-season {
|
||||
font-weight: 700;
|
||||
letter-spacing: 2px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
.year-ring {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: 12px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.year-ring svg { width: 26px; height: 26px; transform: rotate(0deg); }
|
||||
.ring-month {
|
||||
position: absolute;
|
||||
font-size: 0.66rem;
|
||||
color: #d8c392;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* ---------- 成员卡:闭关 aura + 进度流光 ---------- */
|
||||
.aura-dots { position: relative; height: 10px; margin-top: 3px; }
|
||||
.aura-dots i {
|
||||
position: absolute;
|
||||
width: 6px; height: 6px;
|
||||
border-radius: 50%;
|
||||
background: rgba(216, 195, 146, 0.85);
|
||||
animation: aurafloat 2.4s ease-in-out infinite;
|
||||
}
|
||||
.aura-dots i:nth-child(1) { left: 20%; animation-delay: 0s; }
|
||||
.aura-dots i:nth-child(2) { left: 50%; top: -3px; animation-delay: 0.8s; opacity: 0.6; }
|
||||
.aura-dots i:nth-child(3) { left: 76%; animation-delay: 1.6s; }
|
||||
@keyframes aurafloat {
|
||||
0%, 100% { transform: translateY(0); opacity: 0.85; }
|
||||
50% { transform: translateY(-4px); opacity: 0.3; }
|
||||
}
|
||||
.bar.shimmer > div {
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 2.2s linear infinite;
|
||||
}
|
||||
@keyframes shimmer {
|
||||
from { background-position: 200% 0; }
|
||||
to { background-position: -200% 0; }
|
||||
}
|
||||
|
||||
/* ---------- 漂字层(独立节点,互不覆盖) ---------- */
|
||||
.drift-layer {
|
||||
position: fixed;
|
||||
top: 74px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
z-index: 60;
|
||||
pointer-events: none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
.drift {
|
||||
animation: drift-up 2.5s ease-out forwards;
|
||||
font-size: 0.82rem;
|
||||
background: rgba(20, 14, 8, 0.75);
|
||||
padding: 2px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid rgba(150, 120, 60, 0.4);
|
||||
}
|
||||
.drift.pos { color: #b6d49a; }
|
||||
.drift.neg { color: #df9a8a; }
|
||||
@keyframes drift-up {
|
||||
0% { opacity: 0; transform: translateY(8px); }
|
||||
10% { opacity: 1; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 0; transform: translateY(-22px); }
|
||||
}
|
||||
|
||||
/* ---------- 战报刀光 ---------- */
|
||||
html[data-blade] .battle-lines {
|
||||
animation: blade-sweep 0.9s ease-out;
|
||||
}
|
||||
@keyframes blade-sweep {
|
||||
0% { filter: brightness(1); }
|
||||
30% { filter: brightness(1.6) saturate(1.2); }
|
||||
100% { filter: brightness(1); }
|
||||
}
|
||||
|
||||
/* ---------- 卷轴展开(Boot 过渡) ---------- */
|
||||
.boot-frame { animation: scroll-open 0.8s ease-out; }
|
||||
@keyframes scroll-open {
|
||||
from { opacity: 0; transform: translateY(10px) scaleY(0.6); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* ---------- 金角线卡片 ---------- */
|
||||
.gilded::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
border-radius: 4px;
|
||||
background:
|
||||
url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40"><g fill="none" stroke="%23b3913e" stroke-width="1.6" opacity="0.5"><path d="M4 36 L4 4 L36 4"/><path d="M10 30 L10 10 L30 10"/><path d="M16 24 L16 16 L24 16"/></g></svg>') top left / 40px 40px no-repeat;
|
||||
}
|
||||
|
||||
/* ---------- 纸纹 overlay ---------- */
|
||||
.paper-tex {
|
||||
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' seed='7'/><feColorMatrix type='saturate' values='0'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.55'/></svg>");
|
||||
|
||||
Reference in New Issue
Block a user