【Bug2 双排名】MemberModal 顶部与 ModalShell 壳顶重复渲染 (title+category 各两排) → children 内嵌 title/cat 移除(壳已承载),改 mm-brief 一行(寿数/根骨) 【Bug3 定制白屏】App 无错误边界 → 全树崩溃只剩背景 → AppErrorBoundary 一级防线(显示错误信息+重新加载按钮,不再白屏) 【Bug1 推进崩溃】advance 异常 toast 原文只写“已保存”——实为 Stash 静默吞错+双保险缺失 → ①toast 显示根错前 120 字(玩家可见——此前只有 console) ②出险保存双保险: Stash 失败时 localStorage 应急副本+明示“存储也失败”提示手动存档 ③speed timer 内 advance().catch 隔离 【验证】tsc 0 错/build 绿/7000 测试绿
79 lines
3.1 KiB
TypeScript
79 lines
3.1 KiB
TypeScript
import { useEffect } from 'react'
|
|
import { useGameStore } from './ui/store'
|
|
import Boot from './ui/screens/Boot'
|
|
import NewGame from './ui/screens/NewGame'
|
|
import GameScreen from './ui/screens/GameScreen'
|
|
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'
|
|
|
|
import { Component, type ReactNode, type ErrorInfo } from 'react'
|
|
|
|
interface EBState { err: Error | null }
|
|
export class AppErrorBoundary extends Component<{ children: ReactNode }, EBState> {
|
|
state: EBState = { err: null }
|
|
static getDerivedStateFromError(err: Error): EBState { return { err } }
|
|
componentDidCatch(err: Error, info: ErrorInfo): void {
|
|
console.error('[ui-crash]', err, info.componentStack)
|
|
}
|
|
render(): ReactNode {
|
|
if (this.state.err) {
|
|
return (
|
|
<div style={{ padding: 32, color: '#e8d9b0', fontFamily: 'serif' }}>
|
|
<h2 style={{ color: '#d99a85' }}>界面出错了(游戏数据未受影响)</h2>
|
|
<pre style={{ whiteSpace: 'pre-wrap', fontSize: '0.8rem', maxHeight: '50vh', overflow: 'auto' }}>{String(this.state.err?.message ?? this.state.err)}</pre>
|
|
<button className="btn" onClick={() => location.reload()}>重新加载(回档到最近存点)</button>
|
|
</div>
|
|
)
|
|
}
|
|
return this.props.children
|
|
}
|
|
}
|
|
|
|
export default function App() {
|
|
const screen = useGameStore((s) => s.screen)
|
|
const pendingEventId = useGameStore((s) => s.pendingEventId)
|
|
const battleView = useGameStore((s) => s.battleView)
|
|
|
|
useEffect(() => {
|
|
useGameStore.getState().init()
|
|
const clicker = () => sClick()
|
|
document.addEventListener('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 (
|
|
<div className="app">
|
|
{screen === 'boot' && <Boot />}
|
|
{screen === 'newgame' && <NewGame />}
|
|
{screen === 'game' && <GameScreen />}
|
|
{pendingEventId && <EventModal />}
|
|
{battleView && <BattleModal />}
|
|
<PaperModal />
|
|
<DriftLayer />
|
|
</div>
|
|
)
|
|
}
|