v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan
家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统 - Electron + React + TS + MetonaSqlark(aria+OPFS) - 水墨中国风 UI - 引擎种子随机、确定性可回放 - 19 项单元测试、端到端冒烟验证
This commit is contained in:
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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: '气' }
|
||||
]
|
||||
Reference in New Issue
Block a user