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