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: '气' }
]