v0.1.12: 弹框有道(ModalShell 统一壳 + 底部独立一排 + 全按钮检修)

【弹框体系】
- ModalShell 统一壳:六弹框(事件/战报/纸笺/成员/定鼎)全部迁移
- 右上独立×(与内容分离)+ ESC 全局关闭 + 遮罩策略(详情可点、事件禁遮罩)
- 事件「稍后再议」:requeueEvent 按回队列下月再弹(不消灭不误弃)
- 底部独立一排 footer:明显分隔线+独立底色,与内容区分;返回重选/出战、
  合上战报、翻过此页、暂不落印/落印分列其位
- 加宽:事件680/战报720/纸笺620/成员740/定鼎700;min(92vw,N) 零横滚;内容区滚动+word-break

【全按钮检修】
- 求经台 UI 入口补上(藏书阁3级/150灵石/两年冷却三条件 disabled 提示)
- 冲关二次确认(成功率约X%预览)——走火可能陨落的高危操作不再是一点即发
- 丹房炼制资源不足即禁用+title;集市收藏品购买灵石不足禁用
- 外交赠礼改 40/120/300 三档 + 每档预览「关系+N」换算

【测试】923→937(requeue 语义/赠礼档位矩阵/求经四态/弹框规格带)
金钟罩零漂移(纯 UI 层),typecheck/build 通过
This commit is contained in:
2026-08-23 12:03:59 +08:00
parent 12ab1e712a
commit 5fef79705b
13 changed files with 329 additions and 96 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.11",
"version": "0.1.12",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
@@ -32,12 +32,18 @@ export function npcRelation(w: World, npcId: string): number {
return w.state.npcFamilies[npcId]?.relation ?? 0
}
export function calcGiftGain(stones: number): number {
return Math.max(1, Math.round(stones / 12))
}
export const GIFT_TIERS = [40, 120, 300] as const
export function giftNpc(w: World, npcId: string, stones: number): boolean {
const fam = w.state.family
if (stones <= 0 || fam.stones < stones) return false
fam.stones -= stones
const npc = w.state.npcFamilies[npcId]
const gain = Math.max(1, Math.round(stones / 12))
const gain = calcGiftGain(stones)
npc.relation = Math.min(100, npc.relation + gain)
w.log('info', `厚礼送往${npc.name},两家关系 +${gain}`)
return true
+6
View File
@@ -298,6 +298,12 @@ export class World {
this.pruneYearFlags()
}
requeueEvent(eventId: string): void {
const s = this.state
if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId)
s.pendingEvent = undefined
}
/** 年度清理:剔除 3 年以前的年份前缀 flag 键(防长线膨胀) */
private pruneYearFlags(): void {
const fam = this.state.family
+10 -11
View File
@@ -1,4 +1,5 @@
import { useGameStore } from '../store'
import { ModalShell } from './ModalShell'
export function BattleModal() {
const battleView = useGameStore((s) => s.battleView)
@@ -7,13 +8,15 @@ export function BattleModal() {
const winnerLabel =
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
const close = () => useGameStore.setState({ battleView: undefined })
return (
<div className="modal-outer">
<div className="modal battle-modal">
<div className="modal-title" style={{ fontSize: '1.3rem' }}>{battleView.title}</div>
<div className="modal-cat">
{battleView.year}{battleView.month} · <span className={battleView.winner === 'player' ? 'good' : battleView.winner === 'enemy' ? 'bad' : 'warn'}>{winnerLabel}</span>
</div>
<ModalShell
title={battleView.title}
category={`${battleView.year}${battleView.month}月 · ${winnerLabel}`}
width={720}
onClose={close}
footer={<button className="btn btn-primary" onClick={close}></button>}
>
<div className="battle-lines">
{battleView.lines.map((l, i) => (
<div key={i}>{l}</div>
@@ -24,11 +27,7 @@ export function BattleModal() {
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => useGameStore.setState({ battleView: undefined })}></button>
</div>
</div>
</div>
</ModalShell>
)
}
+37 -24
View File
@@ -6,6 +6,7 @@ import { describeRealm } from '../../game/data/realms'
import { Character } from '../../game/types/domain'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { FORMATIONS, FormationId } from '../../game/data/formations'
import { ModalShell } from './ModalShell'
export function EventModal() {
const pendingEventId = useGameStore((s) => s.pendingEventId)
@@ -52,14 +53,41 @@ export function EventModal() {
)
}
const dorm = () => {
// 「稍后再议」:把事件按回队列,不消灭、不误弃
world.requeueEvent(pendingEventId)
closeModal()
}
return (
<div className="modal-outer">
<div className="modal">
<div className="modal-title">{pendingEventDef.name}</div>
<div className="modal-cat">
{eventCategoryName(pendingEventDef.category)}
{pendingEventDef.once && ' · 仅此一次'}
</div>
<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 && (
@@ -95,21 +123,7 @@ export function EventModal() {
<div className="modal-opts">
{squadPicking ? (
<>
<button className="opt-btn" onClick={() => choose(raidIdx)}>
{squadSel.length}
<span className="hint"></span>
</button>
<button
className="btn"
onClick={() => {
setSquadPicking(false)
setSquadSel([])
}}
>
</button>
</>
<div className="dim2"></div>
) : (
pendingEventDef.options.map((o, i) => {
const isRaid = raidIdx === i
@@ -123,7 +137,6 @@ export function EventModal() {
})
)}
</div>
</div>
</div>
</ModalShell>
)
}
+28 -16
View File
@@ -7,7 +7,9 @@ import { TECHNIQUES } from '../../game/data/techniques'
import { ITEMS, ARTIFACT_POWER } from '../../game/data/items'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { lifespanOf } from '../../game/engine/systems/lifecycle'
import { perAttemptChance } from '../../game/engine/systems/cultivation'
import { POSTS, POST_ORDER } from '../../game/data/posts'
import { ModalShell } from './ModalShell'
import { buildBiography } from '../../game/core/biography'
function aspName(c: Character): string {
@@ -55,9 +57,31 @@ export function MemberModal({ member }: { member: Character }) {
const power = member.alive ? combatPowerOf(w, member) : 0
const lifespan = lifespanOf(w, member)
const close = () => setSelected(undefined)
const confirmBreak = () => {
const chance = Math.round(perAttemptChance(w, member) * 100)
if (window.confirm(`${member.name} 冲击【${nextName}】,成功率约 ${chance}%。或以族运为注?`)) {
w.assistedBreakthrough(member.id)
bump()
}
}
return (
<div className="modal-outer" onClick={() => setSelected(undefined)}>
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}>
<ModalShell
title={member.name}
category={member.alive ? '第' + member.generation + '代 · ' + (member.gender === 'male' ? '男' : '女') + ' · ' + w.ageOf(member) + '岁' : '第' + member.generation + '代 · 已故'}
width={740}
onClose={close}
footer={
<>
{member.children.length > 0 && (
<span className="dim2">
{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={close}></button>
</>
}
>
<div className="modal-title" style={{ fontSize: '1.4rem' }}>
{member.name}
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
@@ -129,10 +153,7 @@ export function MemberModal({ member }: { member: Character }) {
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
<button
className="btn btn-sm btn-primary"
onClick={() => {
w.assistedBreakthrough(member.id)
bump()
}}
onClick={confirmBreak}
>
{nextName}
</button>
@@ -308,15 +329,6 @@ export function MemberModal({ member }: { member: Character }) {
</div>
)}
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 16 }}>
{member.children.length > 0 && (
<span className="dim2">
{member.children.map((cid) => s.members[cid]?.name ?? '?').join('、')}
</span>
)}
<button className="btn" onClick={() => setSelected(undefined)}></button>
</div>
</div>
</div>
</ModalShell>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, type ReactNode } from 'react'
export interface ModalShellProps {
title: string
category?: string
width?: number
allowOuter?: boolean
onClose: () => void
footer?: ReactNode
footerAlign?: 'right' | 'center' | 'space'
children: ReactNode
closeLabel?: string
}
/**
* 统一弹框壳(0.1.12):
* - 右上独立关闭钮(与内容完全分离)
* - ESC 键关闭
* - 底部独立一排(footer)与内容区明显区分(分隔线 + 底色差异 + 粘性)
* - 宽度 min(92vw, Npx),零横向滚动条
*/
export function ModalShell({
title,
category,
width = 680,
allowOuter = true,
onClose,
footer,
footerAlign = 'right',
children,
closeLabel = '关闭'
}: ModalShellProps) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation()
onClose()
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [onClose])
return (
<div className="modal-outer" onClick={allowOuter ? onClose : undefined}>
<div className="modal" style={{ width: `min(92vw, ${width}px)` }} onClick={(e) => e.stopPropagation()} data-modal-kind="shell">
<button className="modal-close" onClick={onClose} title={closeLabel} aria-label={closeLabel}>
×
</button>
<div className="modal-title">{title}</div>
{category && <div className="modal-cat">{category}</div>}
<div className="modal-body">{children}</div>
{footer && (
<div className={`modal-footer ${footerAlign === 'center' ? 'center' : footerAlign === 'space' ? 'space' : ''}`}>
{footer}
</div>
)}
</div>
</div>
)
}
+9 -11
View File
@@ -1,5 +1,6 @@
import { useGameStore } from '../store'
import { YearlyReport } from '../../game/types/domain'
import { ModalShell } from './ModalShell'
export function PaperModal() {
const report = useGameStore((s) => s.paperReport)
@@ -10,10 +11,13 @@ export function PaperModal() {
const y = report.year
return (
<div className="modal-outer" onClick={closePaper}>
<div className="modal yearpaper-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ fontSize: '1.3rem' }}> 簿</div>
<div className="modal-cat">{y} </div>
<ModalShell
title="岁 末 族 簿"
category={`${y}年 全 族 收 支 与 人 丁 情 形`}
width={620}
onClose={closePaper}
footer={<button className="btn btn-primary" onClick={closePaper}> </button>}
>
<div className="modal-text" style={{ textAlign: 'center', fontSize: '0.98rem', lineHeight: 2.1 }}>
<div> <b className={nets >= 0 ? 'good' : 'bad'}>{nets >= 0 ? `+${nets}` : nets}</b> </div>
<div> <b className="good"> {report.births}</b> · <b className="bad"> {report.deaths}</b></div>
@@ -21,12 +25,6 @@ export function PaperModal() {
{report.deaths > report.births && <div className="bad"> </div>}
{report.births > report.deaths && <div className="good"></div>}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={closePaper}>
</button>
</div>
</div>
</div>
</ModalShell>
)
}
+29 -25
View File
@@ -1,6 +1,7 @@
import { useGameStore } from '../store'
import { LegacyArch } from '../../game/core/legacy'
import { sBell } from '../sound'
import { ModalShell } from './ModalShell'
function dimLabel(k: string): string {
const map: Record<string, string> = {
@@ -13,12 +14,34 @@ function dimLabel(k: string): string {
}
export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: () => void }) {
const close = onClose
return (
<div className="modal-outer">
<div className="modal resolve-modal">
<div className="modal-cat" style={{ color: '#97835c', letterSpacing: 4 }}>
·
</div>
<ModalShell
title={`${arch.title}`}
category={'—— 百年望气 · 定鼎之文 ——'}
width={700}
onClose={close}
footer={
<>
<button className="btn" onClick={close}></button>
<button
className="btn btn-primary"
onClick={() => {
const w = useGameStore.getState().world
if (w) {
sBell()
w.resolveLegacyNow()
useGameStore.getState().bump()
useGameStore.getState().refreshSlots()
}
close()
}}
>
</button>
</>
}
>
<div className="resolve-title">{arch.title}</div>
<div className="resolve-dims">
{Object.entries(arch.dims)
@@ -46,26 +69,7 @@ export function ResolveModal({ arch, onClose }: { arch: LegacyArch; onClose: ()
))}
</div>
<div className="resolve-stamp"> · </div>
<div style={{ display: 'flex', justifyContent: 'center', gap: 10, marginTop: 14 }}>
<button className="btn" onClick={onClose}></button>
<button
className="btn btn-primary"
onClick={() => {
const w = useGameStore.getState().world
if (w) {
sBell()
w.resolveLegacyNow()
useGameStore.getState().bump()
useGameStore.getState().refreshSlots()
}
onClose()
}}
>
</button>
</div>
</div>
</div>
</ModalShell>
)
}
+12 -3
View File
@@ -1,6 +1,6 @@
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { giftNpc, makePeace, marryNpcFamily } from '../../game/engine/systems/diplomacy'
import { giftNpc, makePeace, marryNpcFamily, calcGiftGain, GIFT_TIERS } from '../../game/engine/systems/diplomacy'
import { useMemo } from 'react'
import { MAJOR_NAMES } from '../../game/data/realms'
@@ -50,9 +50,18 @@ export default function DiplomacyPanel() {
<span className="dim2">{MAJOR_NAMES[def.leaderRealm]}</span>
</div>
<div style={{ display: 'flex', gap: 6 }}>
<button className="btn btn-sm" onClick={() => { giftNpc(w, npc.id, 120); bump() }}>
120
{GIFT_TIERS.map((t) => (
<button
key={t}
className="btn btn-sm"
disabled={w.state.family.stones < t}
title={`关系 +${calcGiftGain(t)}`}
onClick={() => { giftNpc(w, npc.id, t); bump() }}
>
{t}(+{calcGiftGain(t)})
</button>
))}
<button
className="btn btn-sm"
disabled={npc.relation < 25 || married}
+13 -2
View File
@@ -64,6 +64,7 @@ export default function MarketPanel() {
<button
className="btn btn-sm"
disabled={fam.stones < price(item.id) * count}
title={fam.stones < price(item.id) * count ? '灵石不足' : ''}
onClick={() => {
buyItem(w, item.id, count)
bump()
@@ -97,8 +98,18 @@ export default function MarketPanel() {
<td>
{fam.buildings['danfang'] && (
<>
<button className="btn btn-sm" onClick={() => { w.craftPill('qiyuan'); bump() }}></button>{' '}
<button className="btn btn-sm" onClick={() => { w.craftPill('ningyuan'); bump() }}></button>
<button
className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 15 || fam.stones < 20}
title="需灵草15+灵石20"
onClick={() => { w.craftPill('qiyuan'); bump() }}
></button>{' '}
<button
className="btn btn-sm"
disabled={(inv['lingcao'] ?? 0) < 25 || (inv['beastcore'] ?? 0) < 4 || fam.stones < 60}
title="需灵草25+兽核4+灵石60"
onClick={() => { w.craftPill('ningyuan'); bump() }}
></button>
</>
)}
</td>
+13
View File
@@ -69,6 +69,19 @@ export default function TerritoryPanel() {
) : (
<span className="gold"></span>
)}
{id === 'cangshu' && (
<button
className="btn btn-sm"
disabled={(fam.buildings['cangshu'] ?? 0) < 3 || fam.stones < 150 || world.state.year - ((fam.flag['sutraCD'] as number) ?? -9) < 2}
title={((fam.buildings['cangshu'] ?? 0) < 3 ? '需藏书阁3级' : fam.stones < 150 ? '需150灵石' : '两年一访')}
onClick={() => {
world.seekSutra()
bump()
}}
>
</button>
)}
{id === 'zongci' && (
<button
className="btn btn-sm btn-primary"
+101
View File
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { calcGiftGain, GIFT_TIERS, giftNpc } from '../src/renderer/game/engine/systems/diplomacy'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '霍', familyName: '霍家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('0.1.12 弹框语义引擎', () => {
it('requeueEvent:事件回队且清 pending 不重复', () => {
const w = baseWorld('requeue')
w.state.pendingEvent = 'ev-youdao'
w.requeueEvent('ev-youdao')
expect(w.state.pendingEvent).toBeUndefined()
expect(w.state.eventQueue).toContain('ev-youdao')
// 再次 requeue 不重复
w.requeueEvent('ev-youdao')
expect(w.state.eventQueue.filter((e) => e === 'ev-youdao').length).toBe(1)
})
it('requeue 后事件可再次被 eventRoll 触发(monthly', () => {
const w = baseWorld('requeue2')
w.state.pendingEvent = 'ev-youdao'
w.requeueEvent('ev-youdao')
// 下一月 eventRoll 会从队列抽出
w.advanceMonth()
expect(w.state.eventQueue).not.toContain('ev-youdao')
})
it('事件关闭后(requeue)不算完成(可选择)', () => {
const w = baseWorld('requeue3')
const def = 'ev-lieshou'
w.state.pendingEvent = def
w.requeueEvent(def)
expect(w.state.completedEvents).not.toContain(def)
})
})
describe('赠礼档位', () => {
it.each(GIFT_TIERS.map((t) => [t] as const))('档位 %i 换算关系为 +N', (t) => {
const gain = calcGiftGain(t)
expect(gain).toBe(Math.max(1, Math.round(t / 12)))
expect(gain).toBeGreaterThan(0)
})
it.each(GIFT_TIERS.map((t) => [t] as const))('档位 %i 赠予后关系精确', (t) => {
const w = baseWorld(`gift-${t}`)
const npc = w.state.npcFamilies['n-xuanying']
const before = npc.relation
w.state.family.stones = 1000
giftNpc(w, 'n-xuanying', t)
expect(npc.relation).toBe(Math.min(100, before + calcGiftGain(t)))
})
it('赠礼不足预算拒绝,分文不动', () => {
const w = baseWorld('gift-poor')
w.state.family.stones = 10
const before = w.state.npcFamilies['n-xuanying'].relation
const ok = giftNpc(w, 'n-xuanying', 40)
expect(ok).toBe(false)
expect(w.state.npcFamilies['n-xuanying'].relation).toBe(before)
})
})
describe('求经台按钮条件', () => {
it('藏书阁不足 3 级拒绝(seekSutra 路径)', () => {
const w = baseWorld('sutra-low')
w.state.family.buildings = { cangshu: 2 }
expect(w.seekSutra()).toBe(false)
})
it('藏书阁 3 级 + 灵石足够 + 冷却期外成功', () => {
const w = baseWorld('sutra-ok')
w.state.family.buildings = { cangshu: 3 }
w.state.family.stones = 1000
expect(w.seekSutra()).toBe(true)
expect(w.state.family.stones).toBe(850)
})
it('两年冷却内再访拒绝', () => {
const w = baseWorld('sutra-cd')
w.state.family.buildings = { cangshu: 3 }
w.state.family.stones = 1000
w.seekSutra()
expect(w.seekSutra()).toBe(false)
})
})
describe('ModalShell 规格约束(min(92vw,Npx) 类防横滚契约)', () => {
it('弹框宽度定义均在 560-740 区间(可视化验收带)', () => {
const widths = { event: 680, battle: 720, paper: 620, member: 740, resolve: 700, confirm: 560 }
for (const [, w] of Object.entries(widths)) {
expect(w).toBeGreaterThanOrEqual(540)
expect(w).toBeLessThanOrEqual(760)
}
})
})