v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan

家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
This commit is contained in:
2026-08-23 00:04:16 +08:00
commit adb22f0558
69 changed files with 14305 additions and 0 deletions
@@ -0,0 +1,35 @@
import { useGameStore } from '../store'
export function BattleModal() {
const battleView = useGameStore((s) => s.battleView)
if (!battleView) return null
const winnerLabel =
battleView.winner === 'player' ? '✦ 我方得胜 ✦' : battleView.winner === 'enemy' ? '✕ 我方败北 ✕' : '◌ 平分秋色 ◌'
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>
<div className="battle-lines">
{battleView.lines.map((l, i) => (
<div key={i}>{l}</div>
))}
{battleView.losses.length > 0 && (
<div className="bad" style={{ marginTop: 8 }}>
{battleView.losses.join('、')}
</div>
)}
</div>
<div style={{ display: 'flex', justifyContent: 'center' }}>
<button className="btn btn-primary" onClick={() => useGameStore.setState({ battleView: undefined })}></button>
</div>
</div>
</div>
)
}
export default BattleModal
+42
View File
@@ -0,0 +1,42 @@
import { useGameStore } from '../store'
import { applyEventChoice } from '../../game/engine/systems/events'
import { eventCategoryName, EventDef } from '../../game/data/events'
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)
if (!pendingEventId || !pendingEventDef || !world) return null
const choose = (idx: number) => {
applyEventChoice(world, pendingEventId, idx)
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
bump()
// 重要事务后暂停推算,给玩家喘息
const sp = useGameStore.getState().speed
if (sp > 1) setSpeed(1)
}
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>
<div className="modal-text">{pendingEventDef.text}</div>
<div className="modal-opts">
{pendingEventDef.options.map((o, i) => (
<button key={i} className="opt-btn" onClick={() => choose(i)}>
{o.label}
{o.hint && <span className="hint">{o.hint}</span>}
</button>
))}
</div>
</div>
</div>
)
}
+17
View File
@@ -0,0 +1,17 @@
import { useGameStore } from '../store'
export function LogFeed() {
const feed = useGameStore((s) => s.logFeed)
const ref = useGameStore((s) => s.revision)
void ref
return (
<div className="feed">
{feed.slice().reverse().map((item) => (
<div key={item.id} className={`feed-item k-${item.kind}`}>
<span className="feed-date">{item.year}{item.month}</span>
{item.text}
</div>
))}
</div>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useGameStore } from '../store'
import { Character } from '../../game/types/domain'
import { describeRealm } from '../../game/data/realms'
import { describeRoots } from '../../game/data/elements'
const STATE_LABEL: Record<string, { label: string; cls: string }> = {
idle: { label: '无事', cls: '' },
meditation: { label: '闭关', cls: 'good' },
expedition: { label: '出探', cls: 'warn' },
wounded: { label: '养伤', cls: 'bad' }
}
export function MemberCard({ c }: { c: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
if (!world) return null
const dead = !c.alive
const isHead = world.state.family.headId === c.id
const state = STATE_LABEL[c.state]
return (
<div className={`member-card ${dead ? 'dead' : ''}`} onClick={() => setSelected(c.id)}>
<div className="mc-head">
<div className="m-avatar">{world.state.family.surname}</div>
<div style={{ flex: 1 }}>
<div className="member-name">
<span>{c.name}</span>
{isHead && <span className="tag head-t"></span>}
{c.spouseHouse && <span className="tag"></span>}
</div>
<div className="member-realm">
{dead
? `殁于${c.deathYear}年 · ${c.deathCause ?? '寿终'}`
: `${describeRealm(c.realm)} · ${world.ageOf(c)}岁 · 修为${Math.floor(c.realmProgress)}%`}
</div>
</div>
</div>
<div className="member-tags">
<span className="tag">{describeRoots(c.roots)}</span>
{!dead && c.realmProgress >= 100 && <span className="tag gold-t"> · </span>}
{C_STATE[dead ? 'dead' : c.state] && (
<span className={`tag ${C_STATE[dead ? 'dead' : c.state]}`}>{STATE_LABEL[c.state]?.label ?? ''}</span>
)}
</div>
{!dead && (
<div className="mc-foot">
<div className="bar" title="灵气修为" style={{ height: 6 }}>
<div style={{ width: `${c.realmProgress}%` }} />
</div>
</div>
)}
</div>
)
}
const C_STATE: Record<string, string> = {
idle: '',
meditation: 'good',
expedition: 'warn',
wounded: 'bad',
dead: 'bad'
}
+167
View File
@@ -0,0 +1,167 @@
import { useGameStore } from '../store'
import { Character } from '../../game/types/domain'
import { describeRealm, nextRealm } from '../../game/data/realms'
import { describeRoots, ROOT_GRADE_NAMES } from '../../game/data/elements'
import { TRAITS } from '../../game/data/traits'
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'
export function MemberModal({ member }: { member: Character }) {
const world = useGameStore((s) => s.world)
const setSelected = useGameStore((s) => s.setSelected)
const bump = useGameStore((s) => s.bump)
if (!world) return null
const w = world
const s = w.state
const next = nextRealm(member.realm)
const nextName = next ? describeRealm(next) : '已至巅峰'
const tech = TECHNIQUES.find((t) => t.id === member.techniqueId)
const power = member.alive ? combatPowerOf(w, member) : 0
const lifespan = lifespanOf(w, member)
return (
<div className="modal-outer" onClick={() => setSelected(undefined)}>
<div className="modal member-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-title" style={{ fontSize: '1.4rem' }}>
{member.name}
{!member.alive && <span className="bad" style={{ fontSize: '1rem' }}> · </span>}
</div>
<div className="modal-cat">
{member.generation} · {member.gender === 'male' ? '男' : '女'} · {w.ageOf(member)} · 寿{lifespan} · {describeRoots(member.roots)}
</div>
<div className="mm-grid">
<div className="mm-line"><span></span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
<div className="mm-line"><span></span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
<div className="mm-line"><span></span><b>{Math.round(power)}</b></div>
<div className="mm-line"><span></span><b>{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}</b></div>
<div className="mm-line"><span></span><b>{Math.round(member.health)}</b></div>
<div className="mm-line"><span></span><b>{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span></span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
<div className="mm-line"><span></span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
</div>
<div className="bar bar-red" style={{ marginTop: 10 }} title="气血">
<div style={{ width: `${member.health}%` }} />
</div>
<div className="mm-sec">
<div className="mm-grid">
<div className="mm-line"><span></span><b>{member.perception}/10</b></div>
<div className="mm-line"><span></span><b>{member.physique}/10</b></div>
<div className="mm-line"><span></span><b>{member.mind}/10</b></div>
<div className="mm-line"><span></span><b>{member.charm}/10</b></div>
<div className="mm-line"><span></span><b>{member.fortune}/12</b></div>
<div className="mm-line"><span></span><b>{member.fortune ? (member.fortune > 7 ? '俊逸' : '端正') : '—'}</b></div>
</div>
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 4 }}></div>
{member.traits.map((t) => (
<span key={t} className="tag" style={{ marginRight: 4 }}>
{TRAITS[t]?.name ?? t}
</span>
))}
{member.traits.length === 0 && <span className="dim2"></span>}
</div>
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
{member.alive && member.state !== 'expedition' && (
<button
className="btn btn-sm"
onClick={() => {
w.setMeditation(member.id, member.state !== 'meditation')
bump()
}}
>
{member.state === 'meditation' ? '出关' : '闭关'}
</button>
)}
{member.alive && member.realmProgress >= 100 && member.realm.major !== 'spirit' && (
<button
className="btn btn-sm btn-primary"
onClick={() => {
w.assistedBreakthrough(member.id)
bump()
}}
>
{nextName}
</button>
)}
{member.alive && (
<>
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-qiyuan')
bump()
}}
>
</button>
{member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-ningyuan')
bump()
}}
>
</button>
)}
{member.realmProgress >= 100 && member.realm.major !== 'mortal' && (
<button
className="btn btn-sm"
onClick={() => {
w.takePill(member.id, 'pill-pojing')
bump()
}}
>
</button>
)}
</>
)}
</div>
</div>
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{s.family.techniques.map((tid) => (
<button
key={tid}
className="btn btn-sm"
style={{ borderColor: member.techniqueId === tid ? 'var(--gold)' : 'var(--line)' }}
onClick={() => {
w.giveTechnique(member.id, tid)
bump()
}}
>
{TECHNIQUES.find((t) => t.id === tid)?.name ?? tid}
</button>
))}
{s.family.techniques.length === 0 && <span className="dim2"></span>}
</div>
</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>
)
}
+13
View File
@@ -0,0 +1,13 @@
export { LogFeed } from './LogFeed'
export { EventModal } from './EventModal'
export { BattleModal } from './BattleModal'
export { MemberModal } from './MemberModal'
export { MemberCard } from './MemberCard'
export const RES_INFO = [
{ id: 'stones', label: '灵石', icon: '石' },
{ id: 'lingcao', label: '灵草', icon: '草' },
{ id: 'lingkuang', label: '灵矿', icon: '矿' },
{ id: 'beastcore', label: '兽核', icon: '核' },
{ id: 'pill-qiyuan', label: '聚气丹', icon: '气' }
]
+59
View File
@@ -0,0 +1,59 @@
import { useGameStore } from '../store'
import { useMemo, useState } from 'react'
import { ChronicleEntry } from '../../game/types/domain'
const CAT_NAME: Record<string, string> = {
birth: '诞庆',
marriage: '姻缘',
death: '祭丧',
breakthrough: '突破',
battle: '战事',
trade: '商贸',
diplomacy: '外交',
exploration: '寻宝',
building: '营造',
event: '奇遇',
misc: '家常'
}
export default function ChroniclePanel() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
void revision
const [filter, setFilter] = useState<string>('all')
const entries = useMemo(() => {
if (!world) return []
const all = [...world.state.chronicle]
const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter)
return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month))
}, [world, filter])
if (!world) return null
return (
<div>
<div className="help-text">
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 10 }}>
<button className={`btn btn-sm ${filter === 'all' ? 'btn-primary' : ''}`} onClick={() => setFilter('all')}></button>
{Object.entries(CAT_NAME).map(([k, v]) => (
<button key={k} className={`btn btn-sm ${filter === k ? 'btn-primary' : ''}`} onClick={() => setFilter(k)}>
{v}
</button>
))}
</div>
<div className="chronicle-list">
{entries.map((e) => (
<div key={e.id} className={`ch-item ${e.important ? 'gold' : ''}`}>
<span className="ch-year">{e.year}{e.month}</span>
<span>
<span className="tag" style={{ marginRight: 8 }}>{CAT_NAME[e.category] ?? e.category}</span>
{e.text}
</span>
</div>
))}
{entries.length === 0 && <div className="dim2"></div>}
</div>
</div>
)
}
+80
View File
@@ -0,0 +1,80 @@
import { useGameStore } from '../store'
import { npcById } from '../../game/data/npcs'
import { giftNpc, makePeace, marryNpcFamily } from '../../game/engine/systems/diplomacy'
import { useMemo } from 'react'
import { MAJOR_NAMES } from '../../game/data/realms'
const REL_TYPE = (r: number) =>
r >= 80 ? ['死生之交', 'good'] : r >= 50 ? ['通好之势', 'good'] : r >= 25 ? ['友善', 'warn'] : r >= 10 ? ['温和', 'warn'] : r > -10 ? ['中立', ''] : r > -30 ? ['嫌隙', ''] : r > -50 ? ['敌对', 'bad'] : ['仇雠', 'bad']
export default function DiplomacyPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const npcs = Object.values(w.state.npcFamilies)
const sorted = useMemo(() => npcs, [npcs])
return (
<div>
<div className="help-text">
</div>
{sorted.map((npc) => {
const def = npcById(npc.id)
const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied
return (
<div key={npc.id} className="npc-row">
<div className="npc-name">
<div style={{ fontSize: '1.15rem' }}>{npc.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{def.style} · {npc.region}</div>
</div>
<div className="relation-bar">
<div className="bar" style={{ borderColor: npc.relation >= 0 ? '#2e5a2e' : '#6e2f2f' }}>
<div
style={{
width: `${(npc.relation + 100) / 2}%`,
background: npc.relation >= 0 ? '#5a8a44' : '#8a4444'
}}
/>
</div>
<div className={`dim ${relCls}`} style={{ marginTop: 4, fontSize: '0.82rem' }}>
{npc.relation} · {relLabel} · {npc.power}
</div>
</div>
<div className="relation-num">
<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
</button>
<button
className="btn btn-sm"
disabled={npc.relation < 25 || married}
onClick={() => { marryNpcFamily(w, npc.id); bump() }}
>
{married ? '已联姻' : '联姻'}
</button>
<button className="btn btn-sm" onClick={() => { (npc.relation = Math.max(-100, npc.relation - 20)); bump() }}>
</button>
{npc.relation < -30 && (
<button className="btn btn-sm" onClick={() => { makePeace(w, npc.id); bump() }}>
</button>
)}
</div>
</div>
)
})}
<div className="dim2" style={{ marginTop: 12 }}>
</div>
</div>
)
}
+132
View File
@@ -0,0 +1,132 @@
import { useGameStore } from '../store'
import { MISSIONS, missionById } from '../../game/data/secrets'
import { sendMission, recallAll } from '../../game/engine/systems/missions'
import { combatPowerOf } from '../../game/engine/systems/combat'
import { useState } from 'react'
import { describeRealm, MAJOR_ORDER } from '../../game/data/realms'
export default function ExpeditionPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const selectedMissionDef = useGameStore((s) => s.selectedMissionDef)
const setMissionDef = useGameStore((s) => s.setMissionDef)
const revision = useGameStore((s) => s.revision)
void revision
const [squad, setSquad] = useState<string[]>([])
if (!world) return null
const w = world
const s = w.state
const active = s.missions.filter((m) => !m.done)
const candidates = Object.values(s.members).filter(
(c) => c.alive && w.ageOf(c) >= 16 && c.state !== 'expedition'
)
const def = selectedMissionDef ? missionById(selectedMissionDef) : null
const toggle = (id: string) => {
setSquad((old) => (old.includes(id) ? old.filter((x) => x !== id) : [...old, id]))
}
return (
<div>
<div className="help-text">
</div>
<div className="mission-card">
<div className="card-title" style={{ fontSize: '1rem' }}></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill,minmax(180px,1fr))', gap: 8 }}>
{MISSIONS.map((m) => {
const sel = selectedMissionDef === m.id
return (
<div
key={m.id}
style={{
border: sel ? '1px solid var(--gold)' : '1px solid var(--line)',
borderRadius: 4,
padding: '8px 10px',
cursor: 'pointer',
background: 'var(--panel)'
}}
onClick={() => {
setMissionDef(sel ? undefined : m.id)
setSquad([])
}}
>
<div><span className="s-icon">{m.icon}</span> {m.name}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.region} · {describeRealm({ major: m.realmHint, minor: 0 })}</div>
<div className="dim2" style={{ fontSize: '0.78rem' }}>{m.minMembers}-{m.maxMembers} · {m.stages.length}</div>
<div className="dim" style={{ fontSize: '0.8rem', marginTop: 3 }}>{m.desc}</div>
</div>
)
})}
</div>
{def && (
<div style={{ marginTop: 12, borderTop: '1px solid var(--line)', paddingTop: 10 }}>
<div className="dim" style={{ marginBottom: 6 }}>
{squad.length}/{def.maxMembers} {def.minMembers}
</div>
<div className="squad-picker">
{candidates.map((c) => (
<div
key={c.id}
className={`squadian ${squad.includes(c.id) ? 'sel' : ''}`}
onClick={() => toggle(c.id)}
>
{c.name}·{describeRealm(c.realm)}·{Math.round(combatPowerOf(w, c))}
</div>
))}
{candidates.length === 0 && <span className="dim2"></span>}
</div>
<button
className="btn btn-primary"
disabled={squad.length < def.minMembers || squad.length > def.maxMembers || active.length >= 3}
onClick={() => {
sendMission(w, def.id, squad)
setSquad([])
bump()
}}
>
</button>
</div>
)}
</div>
{active.length > 0 && (
<div>
<div className="card-title" style={{ fontSize: '1rem', marginTop: 8 }}></div>
{active.map((m) => {
const def2 = missionById(m.defId)
const stage = def2.stages[m.stage]
return (
<div key={m.id} className="mission-card">
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<b><span className="s-icon">{def2.icon}</span> {def2.name}</b>
<span className="dim2" style={{ fontSize: '0.85rem' }}>
{m.memberIds.map((id) => s.members[id]?.name ?? '?').join('、')}
</span>
<span className="dim" style={{ marginLeft: 'auto', fontSize: '0.85rem' }}>
{m.stage + 1}/{def2.stages.length}
{stage ? ` · ${stage.title}` : ''}
</span>
</div>
<div className="dim2" style={{ fontSize: '0.8rem', margin: '6px 0' }}>
{m.startYear}{m.startMonth} ·
</div>
<div className="dim" style={{ fontSize: '0.85rem', lineHeight: 1.6 }}>
{m.log.slice(-3).map((l, i) => (
<div key={i}>· {l}</div>
))}
</div>
<button className="btn btn-sm" style={{ marginTop: 6 }} onClick={() => { recallAll(w, m.id); bump() }}>
</button>
</div>
)
})}
</div>
)}
</div>
)
}
+52
View File
@@ -0,0 +1,52 @@
import { useGameStore } from '../store'
import { MemberCard } from '../components/MemberCard'
import { MemberModal } from '../components/MemberModal'
import { Character } from '../../game/types/domain'
export default function FamilyPanel() {
const world = useGameStore((s) => s.world)
const selectedMemberId = useGameStore((s) => s.selectedMemberId)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const s = world.state
const alive = Object.values(s.members).filter((c) => c.alive)
const dead = Object.values(s.members).filter((c) => !c.alive)
const sorted = [...alive].sort((a, b) => {
const aHead = a.id === s.family.headId ? -1 : 0
const bHead = b.id === s.family.headId ? -1 : 0
return aHead - bHead || world.ageOf(b) - world.ageOf(a)
})
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
return (
<div>
<div className="card" style={{ marginBottom: 12, display: 'flex', gap: 20, alignItems: 'center' }}>
<div>
<span className="card-title">{s.family.name}</span>{' '}
<span className="dim">{s.family.estate}</span>
</div>
<div style={{ marginLeft: 'auto', display: 'flex', gap: 18 }}>
<span className="dim"> <b className="gold">{alive.length}</b></span>
<span className="dim"> <b className="gold">{s.family.reputation}</b></span>
<span className="dim"> <b className="gold">{s.family.generation}</b></span>
</div>
</div>
<div className="member-grid">
{sorted.map((c) => (
<MemberCard key={c.id} c={c} />
))}
{dead.map((c) => (
<MemberCard key={c.id} c={c} />
))}
</div>
{alive.length === 0 && (
<div className="card" style={{ marginTop: 20, textAlign: 'center' }}>
<div style={{ fontSize: '1.2rem' }}></div>
<div className="dim" style={{ marginTop: 6 }}></div>
</div>
)}
{selected && <MemberModal member={selected} />}
</div>
)
}
+170
View File
@@ -0,0 +1,170 @@
import { useGameStore } from '../store'
import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques'
import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market'
import { useMemo, useState } from 'react'
export default function MarketPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const w = world
const fam = w.state.family
const inv = fam.inventory
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), [])
const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), [])
const marketTech = TECHNIQUES.filter((t) => t.grade <= 3)
const cangshuLv = fam.buildings['cangshu'] ?? 0
const price = (id: string) => marketPrice(w, id)
return (
<div>
<div className="tabs" style={{ padding: 0, background: 'none', border: 'none', marginBottom: 10 }}>
<div className={`tab ${tab === 'goods' ? 'sel' : ''}`} onClick={() => setTab('goods')}></div>
<div className={`tab ${tab === 'tech' ? 'sel' : ''}`} onClick={() => setTab('tech')}></div>
</div>
{tab === 'goods' && (
<>
<div className="help-text">
{Math.round(((fam.flag['priceMult'] as number) ?? 1) * 100)}%
</div>
<table className="market-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th>/</th>
<th></th>
</tr>
</thead>
<tbody>
{[...goods, ...artifacts].map((item) => {
const have = item.id === 'lingcao' || item.id === 'lingkuang' || item.id === 'beastcore' || item.kind === 'pill' || item.kind === 'artifact'
? inv[item.id] ?? 0
: 0
const stock = item.kind === 'artifact' ? 3 : item.kind === 'pill' ? 5 : 9999
const count = item.kind === 'artifact' ? 1 : 5
return (
<tr key={item.id}>
<td>
<b><span className="s-icon">{item.icon}</span> {item.name}</b>
</td>
<td className="dim">{item.desc}</td>
<td>{price(item.id)} </td>
<td className="dim">{have}</td>
<td>
<button
className="btn btn-sm"
disabled={fam.stones < price(item.id) * count}
onClick={() => {
buyItem(w, item.id, count)
bump()
}}
>
{count}
</button>{' '}
{((item.kind === 'resource' || item.kind === 'pill') && have >= count) && (
<button
className="btn btn-sm"
onClick={() => {
sellItem(w, item.id, count)
bump()
}}
>
{count}
</button>
)}
{item.kind === 'artifact' && have > 0 && (
<span className="dim2"> </span>
)}
</td>
</tr>
)
})}
<tr>
<td><b><span className="s-icon"></span> </b></td>
<td className="dim">+ </td>
<td className="dim">{fam.buildings['danfang'] ? `${fam.buildings['danfang']}级丹房` : '未建丹房'}</td>
<td className="dim">{inv['lingcao'] ?? 0}</td>
<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>
</>
)}
</td>
</tr>
</tbody>
</table>
</>
)}
{tab === 'tech' && (
<>
<div className="help-text">
{cangshuLv} {TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]}
</div>
<table className="market-table">
<thead>
<tr>
<th></th>
<th>/</th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{marketTech
.filter((t) => t.grade <= 1 + cangshuLv)
.filter((t) => t.grade <= 3)
.map((t) => {
const owned = fam.techniques.includes(t.id)
const p = t.grade <= 1 + cangshuLv
const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300
return (
<tr key={t.id}>
<td><b>{t.name}</b></td>
<td className="dim">{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}</td>
<td className="dim">{t.desc}</td>
<td>+{Math.round((t.expBonus) * 100)}%</td>
<td>+{Math.round((t.powerBonus) * 100)}%</td>
<td>
{owned ? (
<span className="good"></span>
) : (
<button
className="btn btn-sm"
disabled={!p || fam.stones < priceT}
onClick={() => {
buyTechnique(w, t.id, priceT)
bump()
}}
>
{priceT}
</button>
)}
</td>
</tr>
)
})}
</tbody>
</table>
</>
)}
<div className="dim2" style={{ marginTop: 10 }}>
</div>
</div>
)
}
+68
View File
@@ -0,0 +1,68 @@
import { useGameStore } from '../store'
export default function SettingsPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const slot = useGameStore((s) => s.slot)
const saveNow = useGameStore((s) => s.saveNow)
const exportSave = useGameStore((s) => s.exportSave)
const importSave = useGameStore((s) => s.importSave)
const refreshSlots = useGameStore((s) => s.refreshSlots)
const go = useGameStore((s) => s.go)
const setSpeed = useGameStore((s) => s.setSpeed)
if (!world) return null
return (
<div>
<div className="card-title"></div>
<div className="set-row">
<span> {slot} </span>
<button className="btn btn-sm" onClick={() => void saveNow()}></button>
</div>
<div className="set-row">
<span>JSON </span>
<button className="btn btn-sm" onClick={() => void exportSave()}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => void importSave(slot)}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => void refreshSlots()}></button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => setSpeed(0)}></button>
</div>
<div className="set-row">
<span></span>
<button
className="btn btn-sm"
onClick={() => {
void saveNow('离席前').then(() => {
setSpeed(0)
go('boot')
})
}}
>
</button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={bump}></button>
</div>
<div className="card-title" style={{ marginTop: 20 }}></div>
<div className="help-text">
· <br />
· <br />
· <br />
· <br />
·
</div>
<div className="dim2"> 0.1.0 · Chronicle of the Immortal Clan</div>
</div>
)
}
+102
View File
@@ -0,0 +1,102 @@
import { useGameStore } from '../store'
import { BUILDINGS } from '../../game/data/buildings'
import { useMemo } from 'react'
export default function TerritoryPanel() {
const world = useGameStore((s) => s.world)
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const fam = world.state.family
const ids = useMemo(() => Object.keys(BUILDINGS), [])
const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l)
return (
<div>
<div className="help-text">
</div>
<div className="bld-grid">
{ids.map((id) => {
const def = BUILDINGS[id]
if (!def) return null
const level = fam.buildings[id] ?? 0
const built = level > 0
const cost = built && level >= def.maxLevel ? null : def.upgradeCost(built ? level + 1 : 1)
const produce = def.produceTable ? def.produceTable(built ? level : 1) : null
const canUpgrade =
!!cost &&
fam.stones >= cost.stones &&
fam.inventory['lingkuang'] >= cost.lingkuang
return (
<div key={id} className={`bld-card ${built ? '' : 'dim2'}`}>
<div className="bld-head">
<span className="bld-icon">{def.icon}</span>
<div>
<div>{def.name}</div>
<div className="dim2" style={{ fontSize: '0.75rem' }}>{def.kind === 'produce' ? '产出' : '功效'}</div>
</div>
<span className="bld-lv">{built ? `${level}` : ''}</span>
</div>
<div className="bld-desc">{def.desc}</div>
{produce && Object.keys(produce).length > 0 && (
<div className="bld-prod">
{Object.entries(produce)
.filter(([k]) => k !== 'beastcore' || (built && level >= 3))
.map(([k, v]) => `${itemLabel(k)} ×${v}`)
.join(' / ')}
/
</div>
)}
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginTop: 'auto' }}>
{built ? (
<>
{level < def.maxLevel ? (
<button
className="btn btn-sm"
disabled={!canUpgrade}
onClick={() => {
world.upgrade(id)
bump()
}}
>
{level + 1}{cost?.stones ?? 0} + {cost?.lingkuang ?? 0}
</button>
) : (
<span className="gold"></span>
)}
<span className="dim2" style={{ marginLeft: 'auto' }}>{levelLabel(level)}</span>
</>
) : (
<button
className="btn btn-sm btn-primary"
disabled={!canUpgrade}
onClick={() => {
world.build(id)
bump()
}}
>
{cost?.stones ?? 0} + {cost?.lingkuang ?? 0}
</button>
)}
</div>
</div>
)
})}
</div>
</div>
)
}
function itemLabel(k: string): string {
const map: Record<string, string> = {
stone: '灵石',
lingcao: '灵草',
lingkuang: '灵矿',
beastcore: '兽核'
}
return map[k] ?? k
}
+71
View File
@@ -0,0 +1,71 @@
import { useGameStore } from '../store'
import { SaveMeta } from '../../game/types/domain'
import mountainUrl from '../../assets/mountain.svg'
function slotLabel(meta: SaveMeta | null): string {
if (!meta) return '虚位以待'
return `${meta.name} · ${meta.year}${meta.month}月 · ${meta.members}人 · 声望${meta.reputation}`
}
export default function Boot() {
const slots = useGameStore((s) => s.slots)
const go = useGameStore((s) => s.go)
const continueGame = useGameStore((s) => s.continueGame)
const deleteSave = useGameStore((s) => s.deleteSave)
const toast = useGameStore((s) => s.toast)
return (
<div className="boot">
<div className="boot-bg">
<img src={mountainUrl} alt="" />
</div>
<div className="boot-inner">
<div className="boot-title"></div>
<div className="boot-tagline"> · </div>
<div className="boot-divider" />
<div className="boot-en">CHRONICLE OF THE IMMORTAL CLAN</div>
<div className="boot-actions" style={{ marginTop: 26 }}>
<button className="boot-newgame" onClick={() => go('newgame')}>
</button>
{slots.map((meta, i) => (
<div key={i} className="boot-slot">
{meta && <div className="slot-no">{i + 1}</div>}
{meta ? (
<>
<div style={{ flex: 1, cursor: 'pointer' }} onClick={() => void continueGame(i + 1)}>
<div className="dim2" style={{ fontSize: '0.72rem', color: '#6d5d40' }}>{i + 1}</div>
<div style={{ color: '#dcc898', letterSpacing: '1px' }}>{slotLabel(meta)}</div>
</div>
<button
className="btn btn-sm btn-danger"
title="删除本档"
onClick={() => {
if (window.confirm(`确定删除第 ${i + 1} 档存档?此操作不可撤销。`)) {
void deleteSave(i + 1)
}
}}
>
</button>
</>
) : (
<div className="boot-slot-empty" style={{ flex: 1 }} onClick={() => go('newgame')}>
<span className="slot-no">{i + 1}</span>
<span style={{ color: '#6d5d40' }}></span>
</div>
)}
</div>
))}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginTop: 18 }}>
<span className="seal" style={{ fontSize: '0.62rem' }}></span>
<span style={{ color: '#6d5d40', fontSize: '0.78rem', letterSpacing: '2px' }}>
·
</span>
</div>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
)
}
+113
View File
@@ -0,0 +1,113 @@
import { useGameStore } from '../store'
import { PanelId } from '../store'
import { RES_INFO } from '../components'
import FamilyPanel from '../panels/FamilyPanel'
import TerritoryPanel from '../panels/TerritoryPanel'
import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel'
import ExpeditionPanel from '../panels/ExpeditionPanel'
import ChroniclePanel from '../panels/ChroniclePanel'
import SettingsPanel from '../panels/SettingsPanel'
import { LogFeed } from '../components/LogFeed'
const TABS: { id: PanelId; label: string }[] = [
{ id: 'family', label: '宗族' },
{ id: 'territory', label: '领地' },
{ id: 'market', label: '坊市' },
{ id: 'diplomacy', label: '外交' },
{ id: 'expedition', label: '探秘' },
{ id: 'chronicle', label: '史书' },
{ id: 'settings', label: '设置' }
]
export default function GameScreen() {
const world = useGameStore((s) => s.world)
const panel = useGameStore((s) => s.panel)
const setPanel = useGameStore((s) => s.setPanel)
const revision = useGameStore((s) => s.revision)
const speed = useGameStore((s) => s.speed)
const setSpeed = useGameStore((s) => s.setSpeed)
const advance = useGameStore((s) => s.advance)
const saveNow = useGameStore((s) => s.saveNow)
const toast = useGameStore((s) => s.toast)
const gameOverReason = useGameStore((s) => s.gameOverReason)
void revision
void world
const st = useGameStore.getState()
const w = st.world
if (!w) return null
const s = w.state
const fam = s.family
return (
<div className="app">
<div className="topbar">
<div className="fam-name">{fam.name}</div>
<div className="date">
{s.year}{s.month} · {fam.generation} · {fam.reputation}
</div>
<div className="speed-ctl">
<button className={`speed-btn ${speed === 0 ? 'sel' : ''}`} onClick={() => setSpeed(0)}></button>
<button className={`speed-btn ${speed === 1 ? 'sel' : ''}`} onClick={() => setSpeed(1)}></button>
<button className={`speed-btn ${speed === 2 ? 'sel' : ''}`} onClick={() => setSpeed(2)}></button>
<button className={`speed-btn ${speed === 3 ? 'sel' : ''}`} onClick={() => setSpeed(3)}></button>
<button className="speed-btn" title="推进一月" onClick={() => void advance()}></button>
</div>
<div className="ress">
{RES_INFO.map((r) => (
<div key={r.id} className="res-item" title={r.label}>
<span className="res-icon">{r.icon}</span>
<span>{dispRes(r.id)}</span>
</div>
))}
</div>
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button>
</div>
<div className="tabs">
{TABS.map((t) => (
<div key={t.id} className={`tab ${panel === t.id ? 'sel' : ''}`} onClick={() => setPanel(t.id)}>
{t.label}
</div>
))}
<div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 8 }}>
<span className="dim2">{fam.motto}</span>
</div>
</div>
<div className="main">
<div className="main-left">
{panel === 'family' && <FamilyPanel />}
{panel === 'territory' && <TerritoryPanel />}
{panel === 'market' && <MarketPanel />}
{panel === 'diplomacy' && <DiplomacyPanel />}
{panel === 'expedition' && <ExpeditionPanel />}
{panel === 'chronicle' && <ChroniclePanel />}
{panel === 'settings' && <SettingsPanel />}
{gameOverReason && (
<div className="gameover-banner">
<h1></h1>
<p className="dim" style={{ fontSize: '1.1rem' }}>{gameOverReason}</p>
<p className="dim2" style={{ marginTop: 10 }}>
{s.year} {s.month}
</p>
</div>
)}
</div>
<div className="main-right">
<div className="feed-title"></div>
<LogFeed />
</div>
</div>
{toast && <div className="toast">{toast}</div>}
</div>
)
}
function dispRes(id: string): number {
const st = useGameStore.getState()
const w = st.world
if (!w) return 0
if (id === 'stones') return w.state.family.stones
return w.state.family.inventory[id] ?? 0
}
+93
View File
@@ -0,0 +1,93 @@
import { useMemo, useState } from 'react'
import { useGameStore } from '../store'
import { SURNAME_POOL } from '../../game/core/names'
export default function NewGame() {
const go = useGameStore((s) => s.go)
const startNewGame = useGameStore((s) => s.startNewGame)
const [surname, setSurname] = useState('林')
const [familyName, setFamilyName] = useState('林家')
const [motto, setMotto] = useState('耕读传家,术法继世')
const [difficulty, setDifficulty] = useState<'easy' | 'normal' | 'hard'>('normal')
const [slot, setSlot] = useState(1)
const [randoming, setRandoming] = useState(false)
const seed = useMemo(
() => `${Date.now()}-${Math.floor(Math.random() * 1e9)}-${Math.floor(Math.random() * 1e9)}`,
[randoming]
)
const rollNames = () => {
setRandoming((r) => !r)
const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)]
setSurname(s)
setFamilyName(`${s}`)
}
return (
<div className="newgame">
<div className="newgame-box">
<div className="card-title" style={{ textAlign: 'center', fontSize: '1.4rem' }}>
</div>
<div className="field">
<label></label>
<input value={surname} onChange={(e) => setSurname(e.target.value)} maxLength={2} />
<button className="btn btn-sm" onClick={rollNames}></button>
</div>
<div className="field">
<label></label>
<input value={familyName} onChange={(e) => setFamilyName(e.target.value)} maxLength={10} placeholder="例如:林氏" />
</div>
<div className="field">
<label></label>
<input value={motto} onChange={(e) => setMotto(e.target.value)} maxLength={20} />
</div>
<div className="field">
<label></label>
<div className="diff-grid">
{(
[
['easy', '顺境', '灵石丰足,时来运转'],
['normal', '中庸', '中规中矩,全靠经营'],
['hard', '逆境', '部曲拮据,波折不断']
] as const
).map(([k, label, desc]) => (
<div
key={k}
className={`diff-opt ${difficulty === k ? 'sel' : ''}`}
onClick={() => setDifficulty(k)}
>
<div>{label}</div>
<div className="dim2" style={{ fontSize: '0.78rem', marginTop: 2 }}>{desc}</div>
</div>
))}
</div>
</div>
<div className="field">
<label></label>
<select value={slot} onChange={(e) => setSlot(Number(e.target.value))}>
<option value={1}></option>
<option value={2}></option>
<option value={3}></option>
<option value={4}></option>
</select>
</div>
<div style={{ display: 'flex', gap: 12, justifyContent: 'center', marginTop: 8 }}>
<button className="btn" onClick={() => go('boot')}></button>
<button
className="btn btn-primary btn-lg"
onClick={() => {
void startNewGame({ seed, surname, familyName, motto, difficulty }, slot)
}}
>
</button>
</div>
<div className="dim2" style={{ textAlign: 'center' }}>
</div>
</div>
</div>
)
}
+292
View File
@@ -0,0 +1,292 @@
import { create } from 'zustand'
import { World, WorldEventBus } from '../game/engine/world'
import { EventDef } from '../game/data/events'
import { findEvent, applyEventChoice } from '../game/engine/systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta } from '../game/types/domain'
import { getSlotManager, getSaveSlot } from '../game/storage/db'
import { metaFromState, updateSlotMeta } from './storeHelper'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
export interface GameStore {
screen: Screen
world: World | null
slot: number
slots: (SaveMeta | null)[]
speed: number
timer: ReturnType<typeof setInterval> | null
logFeed: LogItem[]
pendingEventId?: string
pendingEventDef?: EventDef
battleView?: BattleLog
panel: PanelId
selectedMemberId?: string
selectedMissionDef?: string
revision: number
toast?: string
gameOverReason?: string
init: () => Promise<void>
go: (screen: Screen) => void
startNewGame: (opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }, slot: number) => Promise<void>
continueGame: (slot: number) => Promise<void>
loadSnapshot: (slot: number, snapshotId: string) => Promise<void>
advance: () => Promise<void>
addLog: (item: LogItem) => void
onChronicle: (e: ChronicleEntry, important: boolean) => void
onBattle: (log: BattleLog) => void
onPendingEvent: (id: string) => void
onGameOver: (reason: string) => void
saveNow: (label?: string) => Promise<void>
bump: () => void
setSpeed: (v: number) => void
setPanel: (p: PanelId) => void
setSelected: (id?: string) => void
setMissionDef: (id?: string) => void
exportSave: () => Promise<void>
importSave: (slot: number) => Promise<void>
deleteSave: (slot: number) => Promise<void>
refreshSlots: () => Promise<void>
}
const LOG_CAP = 260
let logSeq = 1
export const useGameStore = create<GameStore>((set, get) => ({
screen: 'boot',
world: null,
slot: 1,
slots: [],
speed: 0,
timer: null,
logFeed: [],
pendingEventId: undefined,
pendingEventDef: undefined,
battleView: undefined,
panel: 'family',
selectedMemberId: undefined,
selectedMissionDef: undefined,
revision: 0,
toast: undefined,
gameOverReason: undefined,
init: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
if (typeof window !== 'undefined') {
;(window as unknown as Record<string, unknown>).__cotycBootReady = true
const hack = window as unknown as Record<string, unknown>
hack.__cotycDebug = {
startNewGame: () => get().startNewGame({ seed: 'smoke-' + Date.now(), surname: '林', familyName: '林氏', motto: 'm', difficulty: 'normal' }, 1),
advance: () => get().advance(),
resolvePending: () => {
const st = get()
if (st.pendingEventId && st.world) {
applyEventChoice(st.world, st.pendingEventId, 0)
useGameStore.setState({ pendingEventId: undefined, pendingEventDef: undefined })
st.bump()
}
},
getYear: () => get().world?.state.year ?? 0,
openPanel: (p: string) => {
useGameStore.setState({ panel: p as never })
},
closeBattles: () => {
useGameStore.setState({ battleView: undefined })
},
getChronicle: () => get().world?.state.chronicle?.slice().reverse() ?? [],
getFeed: () => get().logFeed.slice()
}
}
},
go: (screen) => set({ screen }),
startNewGame: async (opts, slot) => {
const world = World.create(opts)
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 })
const st = get()
st.world?.out.push(makeBus(st))
await st.saveNow('开局')
},
continueGame: async (slot) => {
const manager = getSlotManager()
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState()
if (!state) throw new Error('找不到存档数据')
openState(state, slot)
await manager.updateSlotMeta(slot, metaFromState(state, slot))
},
loadSnapshot: async (slot, snapshotId) => {
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState(snapshotId)
if (!state) throw new Error('找不到快照')
openState(state, slot)
},
advance: async () => {
const st = get()
const w = st.world
if (!w || st.pendingEventId || w.state.gameOver) return
try {
w.advanceMonth()
w.syncRng()
set((s) => ({ revision: s.revision + 1 }))
await Stash.save(st, '自动')
} catch (e) {
console.error(e)
}
},
addLog: (item) => {
set((s) => {
const next = (s.logFeed.length >= LOG_CAP ? s.logFeed.slice(s.logFeed.length - LOG_CAP + 1) : s.logFeed).concat(item)
return { logFeed: next }
})
},
onChronicle: (e, important) => {
if (important) {
set((s) => ({ revision: s.revision + 1 }))
}
},
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
onPendingEvent: (id) => {
const def = findEvent(id)
set({ pendingEventId: id, pendingEventDef: def })
},
onGameOver: (reason) => set({ gameOverReason: reason, speed: 0, revision: get().revision + 1 }),
saveNow: async (label = '手动') => {
await Stash.save(get(), label)
},
bump: () => set((s) => ({ revision: s.revision + 1 })),
setSpeed: (v) => {
const st = get()
if (st.timer) clearInterval(st.timer)
if (v <= 0) {
set({ speed: v, timer: null })
return
}
const ms = v === 1 ? 3200 : v === 2 ? 1800 : 900
if (v >= 3) v = 3
const timer = setInterval(() => {
const cur = get()
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) {
void cur.advance()
}
}, ms)
set({ speed: v, timer })
},
setPanel: (p) => set({ panel: p }),
setSelected: (id) => set({ selectedMemberId: id }),
setMissionDef: (id) => set({ selectedMissionDef: id }),
exportSave: async () => {
const st = get()
if (!st.world || !window.api) return
const file = await getSaveSlot(st.slot)
const meta0 = metaFromState(st.world.state, st.slot)
st.world.syncRng()
await file.saveState(st.world.state, '导出前')
const json = await file.exportAll()
const meta = metaFromState(st.world.state, st.slot)
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}`)
if (r.ok) set({ toast: '存档已导出。' })
else set({ toast: `导出失败:${r.error ?? ''}` })
},
importSave: async (slot) => {
if (!window.api) return
const r = await window.api.importSave()
if (!r.ok || !r.text) {
set({ toast: `导入失败:${r.error ?? ''}` })
return
}
const file = await getSaveSlot(slot)
await file.open()
const ok = await file.importAll(r.text)
if (ok) {
set({ toast: '导入成功,重新读取存档槽。' })
await get().refreshSlots()
} else {
set({ toast: '导入失败:文件格式不正确。' })
}
},
deleteSave: async (slot) => {
const file = await getSaveSlot(slot)
await file.open()
await file.wipe()
const manager = getSlotManager()
await manager.removeSlotMeta(slot)
await get().refreshSlots()
},
refreshSlots: async () => {
const manager = getSlotManager()
const slots = await manager.listSlotMetas()
set({ slots })
}
}))
function openState(state: GameState, slot: number): void {
const world = new World(state)
const st = useGameStore.getState()
world.out.push(makeBus(st))
useGameStore.setState({
world,
slot,
screen: 'game',
panel: 'family',
logFeed: [],
battleView: undefined,
pendingEventId: undefined,
pendingEventDef: undefined,
revision: 1,
speed: 0,
gameOverReason: state.gameOver?.reason
})
}
function makeBus(st: GameStore): WorldEventBus {
return {
onLog: (kind, text) => {
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
onChronicle: (e, important) => {
st.addLog({ id: logSeq++, kind: 'chronicle', text: `${e.year}${e.month}${e.text}`, year: e.year, month: e.month })
if (important) st.onChronicle(e, important)
},
onBattle: (log) => st.onBattle(log),
onPendingEvent: (id) => st.onPendingEvent(id),
onGameOver: (reason) => st.onGameOver(reason)
}
}
const Stash = {
async save(st: GameStore, label: string): Promise<void> {
if (!st.world) return
const file = await getSaveSlot(st.slot)
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
await file.saveChronicle(st.world.state.chronicle)
const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta)
await updateSlotMeta(st.slot, meta)
}
}
+24
View File
@@ -0,0 +1,24 @@
import { GameState, SaveMeta } from '../game/types/domain'
import { getSlotManager } from '../game/storage/db'
export function metaFromState(state: GameState, slot: number): SaveMeta {
const alive = Object.values(state.members).filter((c) => c.alive).length
return {
slot,
surname: state.family.surname,
name: state.family.name,
estate: state.family.estate,
year: state.year,
month: state.month,
generation: state.family.generation,
members: alive,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: state.schemaVersion
}
}
export async function updateSlotMeta(slot: number, meta: SaveMeta): Promise<void> {
const manager = getSlotManager()
await manager.updateSlotMeta(slot, meta)
}
File diff suppressed because it is too large Load Diff