Files
ChronicleOfTheImmortalClan/src/renderer/ui/components/EventModal.tsx
T
thzxx 2369f342dd v0.1.33c: UI/动效打磨——过渡动画/停帧节能/季节对齐/反馈闭环
【动效】Modal 淡入+升起缓动(modalFadeIn/modalRise);LogFeed 入场动画(feedIn);
fx std 档放行刀光(战场核心反馈);任务凯旋补 spark/战殁补 ripple
【节能】DriftLayer 2.6s 转 onAnimationEnd 删除节点(消除空转 setInterval×slice 定期 setState);
fx-canvas 粒子空态停帧(空 rAF 取消+spawnBurst 唤醒)——低端机待机零开销
【季节】fx-canvas 季节色改读 data-season(游戏月驱动,不再用系统月——夏季粒子不再飘冬季色)
【性能】transition all 12 处→具体属性(background-color/color/transform/opacity);
backdrop-filter 保留(视觉主)
【UI 修复】MemberModal 装备栏空守卫(未知名器)+职事按钮 disabled={maxed} 统一;
议和按钮灵石不足 disabled+title(不再静默);事件弹窗 RES_NAME 补新材料/增筑显示中文名;
回卷按钮无存点 disabled+title;速度按钮 sel/gel 类名统一;MarketPanel 删死变量 stock;
debug 后门 __SMOKE__/DEV 门控(生产干净,冒烟仍可用——main 注入 __SMOKE__)
【测试】fx-timesense std 刀光断言更新 5030 全绿
2026-08-23 21:38:02 +08:00

180 lines
7.3 KiB
TypeScript

import { useState } from 'react'
import { useGameStore } from '../store'
import { applyEventChoice, rankPower } from '../../game/engine/runtime/Systems/events'
import { eventCategoryName } from '../../game/data/events'
import { describeRealm } from '../../game/data/realms'
import { Character } from '../../game/types/domain'
import { combatPowerOf } from '../../game/engine/runtime/Systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations'
import { buildingById } from '../../game/data/buildings'
import { ModalShell } from './ModalShell'
const RES_NAME: Record<string, string> = {
lingcao: '灵草', lingkuang: '灵矿', beastcore: '兽核', stones: '灵石',
lingyu: '灵玉', lingmu: '灵木', shoupi: '兽皮', lingguo: '灵果', linglu: '灵露', dansha: '丹砂', fuzhi: '符纸',
'talisman-feng': '风符', 'talisman-shan': '山符', 'brew-niang': '灵酿'
}
function describeEff(eff: Record<string, never> | { [k: string]: unknown }): string {
const parts: string[] = []
const e = eff as { res?: Record<string, number>; rep?: number; relation?: Record<string, number>; addBuilding?: string; pillGain?: Record<string, number>; mission?: string; addTech?: string; raid?: { npcId: string }; trib?: { mode: string }; feisheng?: { stay: boolean }; techniqueChance?: number; artifactChance?: number; flag?: Record<string, unknown> }
if (e.res) {
for (const [k, v] of Object.entries(e.res)) {
const name = RES_NAME[k] ?? k
parts.push(v > 0 ? `${name}+${v}` : `${name}${v}`)
}
}
if (e.rep) parts.push(`声望${e.rep > 0 ? '+' : ''}${e.rep}`)
if (e.relation) {
for (const v of Object.values(e.relation)) parts.push(v > 0 ? `交好+${v}` : `交恶${v}`)
}
if (e.addBuilding) parts.push('增筑「' + (buildingById(e.addBuilding)?.name ?? e.addBuilding) + '」')
if (e.pillGain) {
for (const v of Object.values(e.pillGain)) parts.push(`得丹+${v}`)
}
if (e.addTech) parts.push('得功法')
if (e.mission) parts.push('触发任务')
if (e.techniqueChance) parts.push(`功法机率+${Math.round(e.techniqueChance * 100)}%`)
if (e.artifactChance) parts.push(`宝器机率+${Math.round(e.artifactChance * 100)}%`)
if (e.raid) parts.push('来敌犯境')
if (e.trib) {
parts.push(e.trib.mode === 'rash' ? '硬渡天劫' : e.trib.mode === 'guard' ? '护法渡劫' : '渡劫延一年')
}
if (e.feisheng) parts.push(e.feisheng.stay ? '留世不飞升' : '飞升离世')
return parts.join(' · ')
}
export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId)
const pendingEventDef = useGameStore((s) => s.pendingEventDef)
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const setSpeed = useGameStore((s) => s.setSpeed)
const [squadPicking, setSquadPicking] = useState(false)
const [squadSel, setSquadSel] = useState<string[]>([])
const [formation, setFormation] = useState<FormationId>('vanguard')
if (!pendingEventId || !pendingEventDef || !world) return null
const raidIdx = pendingEventDef.options.findIndex((o) => o.eff.raid || o.eff.tournament)
const squadPool: Character[] =
raidIdx >= 0
? world.aliveMembers()
.filter((c) => world.ageOf(c) >= 16 && c.state !== 'expedition')
.sort((a, b) => rankPower(world, b) - rankPower(world, a))
: []
const closeModal = () => {
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
setSquadPicking(false)
setSquadSel([])
bump()
}
const choose = (idx: number) => {
const isRaid = raidIdx >= 0 && raidIdx === idx
if (isRaid && !squadPicking) {
setSquadPicking(true)
setSquadSel(squadPool.slice(0, 4).map((c) => c.id))
return
}
applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined, undefined, isRaid ? formation : undefined)
closeModal()
const sp = useGameStore.getState().speed
if (sp > 1) setSpeed(1)
}
const toggleSquad = (id: string) => {
setSquadSel((old) =>
old.includes(id) ? old.filter((x) => x !== id) : old.length >= 4 ? [...old.slice(1), id] : [...old, id]
)
}
const dorm = () => {
// 「稍后再议」:把事件按回队列,不消灭、不误弃
world.requeueEvent(pendingEventId)
closeModal()
}
return (
<ModalShell
title={pendingEventDef.name}
category={`${eventCategoryName(pendingEventDef.category)}${pendingEventDef.once ? ' · 仅此一次' : ''}`}
width={680}
allowOuter={false}
onClose={dorm}
closeLabel="稍后再议"
footer={
squadPicking ? (
<>
<button
className="btn"
onClick={() => {
setSquadPicking(false)
setSquadSel([])
}}
>
返回重选
</button>
<button className="btn btn-primary" disabled={squadSel.length === 0} onClick={() => choose(raidIdx)}>
{squadSel.length === 0 ? '族中无可战之人' : `出战!(共${squadSel.length}人)`}
</button>
</>
) : (
<button className="btn" onClick={dorm}>稍后再议</button>
)
}
>
<div className="modal-text">{pendingEventDef.text}</div>
{squadPicking && (
<div className="modal-squad" style={{ marginBottom: 14 }}>
<div className="dim" style={{ marginBottom: 6 }}>迎战·列阵(默认四人,可改动)</div>
<div className="formation-pick" style={{ marginBottom: 8 }}>
{(Object.keys(FORMATIONS) as FormationId[]).map((fid) => (
<div
key={fid}
className={`tag ${formation === fid ? 'gold-t' : ''}`}
style={{ cursor: 'pointer', padding: '3px 10px' }}
onClick={() => setFormation(fid)}
title={FORMATIONS[fid].desc}
>
{FORMATIONS[fid].name}·{FORMATIONS[fid].desc}
</div>
))}
</div>
<div className="squad-picker">
{squadPool.map((c) => (
<div
key={c.id}
className={`squadian ${squadSel.includes(c.id) ? 'sel' : ''}`}
onClick={() => toggleSquad(c.id)}
>
{c.name}·{describeRealm(c.realm)}·战力{Math.round(combatPowerOf(world, c))}
</div>
))}
{squadPool.length === 0 && <span className="dim2">无可出战之人</span>}
</div>
</div>
)}
<div className="modal-opts">
{squadPicking ? (
<div className="dim2">依选迎战(可继续点选调整阵容)</div>
) : (
pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i
return (
<button key={i} className="opt-btn" onClick={() => choose(i)}>
{o.label}
{isRaid && <span className="hint">点将迎战 · 可自选阵容</span>}
{!isRaid && o.hint && <span className="hint">{o.hint}</span>}
<span className="opt-eff">{describeEff(o.eff as never)}</span>
</button>
)
})
)}
</div>
</ModalShell>
)
}