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