v0.1.2: 宗族气韵(职事/悟道/谱系/碑录/庆典/飞升/音效)

- 职事任命:长老(+修炼)/供奉(+战力)/执事(+坊市)/掌教(+闭关) 每人加成叠加,上限管理
- 功法悟道:修习积悟性点,小成/大成逐级+战力,大境界突破传道于直系晚辈
- 宗族谱系页:世代分带 + 夫妻同卷 + 子孙连枝,点节点开详情,先人簿可查
- 功德碑:宗主辞世即勒石,宗祠页碑录永存
- 百年庆典 + 飞升之路事件链(留世坐镇/仙风入体 或 云游飞升/宗族蒙荫)
- 合成音效(WebAudio):鼓点/钟鸣/纸韵/磬响,设置页开关
- 单测 29→38;typecheck/build 通过;GUI 冒烟受限于 WSLg 掉线(SMOKE-TIMEOUT 优雅退出)
This commit is contained in:
2026-08-23 08:28:49 +08:00
parent 6b15a97d0c
commit fefbe1f1d7
22 changed files with 864 additions and 8 deletions
@@ -52,6 +52,11 @@ export function MemberCard({ c }: { c: Character }) {
)
}
function postLabel(c: Character): string {
const map: Record<string, string> = { head: '家主', elder: '长老', guardian: '供奉', steward: '执事', master: '掌教' }
return map[c.post ?? ''] ?? ''
}
const C_STATE: Record<string, string> = {
idle: '',
meditation: 'good',
@@ -7,6 +7,7 @@ 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'
import { POSTS, POST_ORDER } from '../../game/data/posts'
function equipableItems(member: Character, s: GameState): string[] {
const inv = Object.keys(ARTIFACT_POWER).filter((ai) => (s.family.inventory[ai] ?? 0) > 0)
@@ -185,6 +186,42 @@ export function MemberModal({ member }: { member: Character }) {
</div>
)}
{member.alive && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<button
className={`btn btn-sm ${!member.post ? 'btn-primary' : ''}`}
onClick={() => {
w.assignPost(member.id, undefined)
bump()
}}
>
</button>
{POST_ORDER.filter((pid) => pid !== 'head').map((pid) => {
const def = POSTS[pid]
const maxed = w.postCount(pid) >= def.max && member.post !== pid
return (
<button
key={pid}
className={`btn btn-sm ${member.post === pid ? 'btn-primary' : ''}`}
disabled={maxed && !!member.post}
style={maxed && !member.post ? { opacity: 0.45 } : undefined}
title={`${def.desc}${w.postCount(pid)}/${def.max} 在任`}
onClick={() => {
w.assignPost(member.id, pid)
bump()
}}
>
{def.name}{w.postCount(pid)}/{def.max}
</button>
)
})}
</div>
</div>
)}
{member.alive && w.canMarry(member.id) && (
<div className="mm-sec">
<div className="dim" style={{ marginBottom: 6 }}></div>
+128
View File
@@ -0,0 +1,128 @@
import { useGameStore } from '../store'
import { computeGenealogy } from '../../game/core/genealogy'
import { describeRealm } from '../../game/data/realms'
import { Character, FamilyState } from '../../game/types/domain'
import { useState } from 'react'
import { MemberModal } from '../components/MemberModal'
export default function GenealogyPanel() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
const selectedMemberId = useGameStore((s) => s.selectedMemberId)
const setSelected = useGameStore((s) => s.setSelected)
const [deceasedTab, setDeceasedTab] = useState(false)
void revision
if (!world) return null
const w = world
const s = w.state
const rows = computeGenealogy(w)
const deceased = !deceasedTab
? []
: Object.values(s.members).filter((c) => !c.alive).sort((a, b) => (a.deathYear ?? 0) - (b.deathYear ?? 0))
const selected = selectedMemberId ? s.members[selectedMemberId] as Character | undefined : undefined
return (
<div>
<div className="help-text">
</div>
<div style={{ display: 'flex', gap: 6, marginBottom: 10 }}>
<button
className={`btn btn-sm ${!deceasedTab ? 'btn-primary' : ''}`}
onClick={() => setDeceasedTab(false)}
>
</button>
<button className={`btn btn-sm ${deceasedTab ? 'btn-primary' : ''}`} onClick={() => setDeceasedTab(true)}>
簿{Object.values(s.members).filter((c) => !c.alive).length}
</button>
</div>
{!deceasedTab &&
rows.map((row) => (
<div key={row.gen} className="genealogy-row">
<div className="genealogy-gen">{row.gen}</div>
<div className="genealogy-body">
{row.units.map((unit, i) => (
<div key={i} className="genealogy-unit">
<div className="genealogy-parents">
{unit.parents[0] && <GeneNode c={unit.parents[0]} w={w} />}
<div className="genealogy-matron">×</div>
{unit.parents[1] && <GeneNode c={unit.parents[1]} w={w} />}
</div>
{unit.children.length > 0 && (
<>
<div className="genealogy-drop" />
<div className="genealogy-children">
{unit.children.map((ch) => (
<GeneNode key={ch.id} c={ch} w={w} />
))}
</div>
</>
)}
</div>
))}
{row.singles.length > 0 && (
<div className="genealogy-singles">
{row.singles.map((c) => (
<GeneNode key={c.id} c={c} w={w} />
))}
</div>
)}
{row.units.length + row.singles.length === 0 && <div className="dim2"></div>}
</div>
</div>
))}
{deceasedTab &&
deceased.map((c) => (
<div key={c.id} className="set-row" style={{ maxWidth: 760 }}>
<span>
<span className="ch-year" style={{ marginRight: 10 }}>{c.deathYear}</span>
{c.name} · {c.gender === 'male' ? '男' : '女'} · {c.generation}
<span className="dim2" style={{ marginLeft: 8 }}>{c.deathCause ?? '寿终'}</span>
</span>
<button className="btn btn-sm" onClick={() => setSelected(c.id)}></button>
</div>
))}
{selected && <MemberModal member={selected} />}
</div>
)
}
function GeneNode({ c, w }: { c: Character; w: NonNullable<ReturnType<typeof useGameStore.getState>['world']> }) {
const setSelected = useGameStore((s) => s.setSelected)
const isHead = w.state.family.headId === c.id
const rank = c.techniqueRank ?? 0
const post = POST_NAME[c.post ?? ''] ?? ''
return (
<div
className={`gene-node ${c.alive ? '' : 'gene-dead'} ${isHead ? 'gene-head' : ''}`}
onClick={() => setSelected(c.id)}
title={`${c.name} · ${describeRealm(c.realm)} · ${w.ageOf(c)}`}
>
<div className="gene-name">
{c.name}
{isHead && <span className="tag head-t" style={{ marginLeft: 4 }}></span>}
{post && <span className="tag" style={{ marginLeft: 4 }}>{post}</span>}
{!c.alive && <span className="tag" style={{ marginLeft: 4 }}></span>}
</div>
<div className="gene-sub">
{c.alive ? `${describeRealm(c.realm)} · ${w.ageOf(c)}` : `${c.deathCause ?? '寿终'}${c.deathYear ? `${c.deathYear}年)` : ''}`}
{rank > 0 && <span className="tag gold-t" style={{ marginLeft: 4 }}>{rank}</span>}
</div>
</div>
)
}
const POST_NAME: Record<string, string> = {
head: '家主',
elder: '长老',
guardian: '供奉',
steward: '执事',
master: '掌教'
}
export type { FamilyState }
+8
View File
@@ -13,6 +13,8 @@ export default function SettingsPanel() {
const snapshots = useGameStore((s) => s.snapshots)
const go = useGameStore((s) => s.go)
const setSpeed = useGameStore((s) => s.setSpeed)
const soundOn = useGameStore((s) => s.soundOn)
const toggleSound = useGameStore((s) => s.toggleSound)
if (!world) return null
return (
@@ -63,6 +65,12 @@ export default function SettingsPanel() {
<span></span>
<button className="btn btn-sm" onClick={() => void refreshSlots()}></button>
</div>
<div className="set-row">
<span>··</span>
<button className="btn btn-sm" onClick={toggleSound}>
{soundOn ? '开(点击关)' : '关(点击开)'}
</button>
</div>
<div className="set-row">
<span></span>
<button className="btn btn-sm" onClick={() => setSpeed(0)}></button>
+20
View File
@@ -104,6 +104,26 @@ export default function TerritoryPanel() {
)
}
function MonumentList() {
const world = useGameStore((s) => s.world)
const revision = useGameStore((s) => s.revision)
void revision
if (!world) return null
const monuments = world.state.chronicle.filter((e) => e.text.startsWith('功德碑')).slice().reverse()
if (monuments.length === 0) return null
return (
<div className="monument-strip">
<div className="card-title" style={{ fontSize: '0.98rem' }}></div>
{monuments.slice(0, 4).map((m) => (
<div key={m.id} className="dim" style={{ lineHeight: 1.7 }}>
<span className="ch-year" style={{ marginRight: 8 }}>{m.year}</span>
{m.text.slice(3)}
</div>
))}
</div>
)
}
function itemLabel(k: string): string {
const map: Record<string, string> = {
stone: '灵石',
+3
View File
@@ -2,6 +2,7 @@ import { useGameStore } from '../store'
import { PanelId } from '../store'
import { RES_INFO } from '../components'
import FamilyPanel from '../panels/FamilyPanel'
import GenealogyPanel from '../panels/GenealogyPanel'
import TerritoryPanel from '../panels/TerritoryPanel'
import MarketPanel from '../panels/MarketPanel'
import DiplomacyPanel from '../panels/DiplomacyPanel'
@@ -12,6 +13,7 @@ import { LogFeed } from '../components/LogFeed'
const TABS: { id: PanelId; label: string }[] = [
{ id: 'family', label: '宗族' },
{ id: 'genealogy', label: '谱系' },
{ id: 'territory', label: '领地' },
{ id: 'market', label: '坊市' },
{ id: 'diplomacy', label: '外交' },
@@ -78,6 +80,7 @@ export default function GameScreen() {
<div className="main">
<div className="main-left">
{panel === 'family' && <FamilyPanel />}
{panel === 'genealogy' && <GenealogyPanel />}
{panel === 'territory' && <TerritoryPanel />}
{panel === 'market' && <MarketPanel />}
{panel === 'diplomacy' && <DiplomacyPanel />}
+115
View File
@@ -0,0 +1,115 @@
// 合成音效:鼓点·钟鸣·纸韵(Web Audio,零素材依赖)
let ctx: AudioContext | null = null
let enabled = true
export function setSoundEnabled(on: boolean): void {
enabled = on
}
export function isSoundEnabled(): boolean {
return enabled
}
function ac(): AudioContext | null {
if (!enabled) return null
try {
if (!ctx) {
const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext
ctx = new AC()
}
if (ctx.state === 'suspended') void ctx.resume()
return ctx
} catch {
return null
}
}
function tone(freq: number, dur: number, gain: number, type: OscillatorType = 'sine', when = 0, slideTo?: number): void {
const a = ac()
if (!a) return
const t0 = a.currentTime + when
const o = a.createOscillator()
const g = a.createGain()
o.type = type
o.frequency.setValueAtTime(freq, t0)
if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t0 + dur)
g.gain.setValueAtTime(0, t0)
g.gain.linearRampToValueAtTime(gain, t0 + 0.012)
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
o.connect(g)
g.connect(a.destination)
o.start(t0)
o.stop(t0 + dur + 0.05)
}
function noise(dur: number, gain: number, when = 0, freq = 3200): void {
const a = ac()
if (!a) return
const t0 = a.currentTime + when
const len = Math.floor(a.sampleRate * dur)
const buf = a.createBuffer(1, len, a.sampleRate)
const data = buf.getChannelData(0)
for (let i = 0; i < len; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / len)
const src = a.createBufferSource()
src.buffer = buf
const f = a.createBiquadFilter()
f.type = 'highpass'
f.frequency.value = freq
const g = a.createGain()
g.gain.setValueAtTime(gain, t0)
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur)
src.connect(f)
f.connect(g)
g.connect(a.destination)
src.start(t0)
}
/** 月度推进:极轻的硖墨声 */
export function sTick(): void {
noise(0.05, 0.015, 0, 5000)
}
/** 开卷 / 纸笺 */
export function sPaper(): void {
noise(0.12, 0.03, 0, 2400)
tone(620, 0.1, 0.015, 'sine', 0.02)
}
/** 好消息:铜钟一声 */
export function sGood(): void {
tone(660, 0.5, 0.06, 'sine')
tone(990, 0.42, 0.03, 'sine', 0.05)
tone(1320, 0.3, 0.018, 'sine', 0.09)
}
/** 坏消息:折磬低响 */
export function sBad(): void {
tone(196, 0.4, 0.05, 'triangle', 0, 130)
tone(131, 0.5, 0.04, 'triangle', 0.04, 82)
}
/** 战报:鼓点急雨 */
export function sWar(): void {
tone(120, 0.16, 0.07, 'sine', 0, 60)
tone(120, 0.16, 0.06, 'sine', 0.16, 60)
tone(160, 0.3, 0.06, 'sine', 0.32, 80)
noise(0.28, 0.02, 0.36, 1500)
}
/** 钟鸣:大事件(碑立/飞升) */
export function sBell(): void {
tone(392, 1.4, 0.06, 'sine')
tone(494, 1.2, 0.035, 'sine', 0.08)
tone(587, 1.0, 0.028, 'sine', 0.16)
}
/** 突破:磬缶清越 */
export function sGong(): void {
tone(440, 0.9, 0.05, 'sine')
tone(660, 0.7, 0.03, 'sine', 0.06)
}
/** 按钮:纸面轻叩 */
export function sClick(): void {
noise(0.045, 0.03, 0, 4200)
}
+17 -1
View File
@@ -5,9 +5,10 @@ import { findEvent, applyEventChoice } from '../game/engine/systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain'
import { getSlotManager, getSaveSlot } from '../game/storage/db'
import { metaFromState, updateSlotMeta } from './storeHelper'
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sGong, sClick, sTick } from './sound'
export type Screen = 'boot' | 'newgame' | 'game'
export type PanelId = 'family' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
export type PanelId = 'family' | 'genealogy' | 'territory' | 'market' | 'diplomacy' | 'expedition' | 'chronicle' | 'settings'
export interface GameStore {
screen: Screen
@@ -28,6 +29,7 @@ export interface GameStore {
gameOverReason?: string
paperReport?: YearlyReport
snapshots: SnapshotMeta[]
soundOn: boolean
init: () => Promise<void>
go: (screen: Screen) => void
@@ -54,6 +56,7 @@ export interface GameStore {
refreshSnapshots: () => Promise<void>
onYearPaper: (report: YearlyReport) => void
closePaper: () => void
toggleSound: () => void
}
const LOG_CAP = 260
@@ -78,6 +81,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
gameOverReason: undefined,
paperReport: undefined,
snapshots: [],
soundOn: true,
init: async () => {
const manager = getSlotManager()
@@ -145,6 +149,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
try {
w.advanceMonth()
w.syncRng()
sTick()
set((s) => ({ revision: s.revision + 1 }))
await Stash.save(st, '自动')
} catch (e) {
@@ -176,12 +181,19 @@ export const useGameStore = create<GameStore>((set, get) => ({
onYearPaper: (report) => {
if (report.year >= 2) {
sPaper()
set({ paperReport: report, speed: 0 })
}
},
closePaper: () => set({ paperReport: undefined }),
toggleSound: () => {
const on = !get().soundOn
set({ soundOn: on })
setSoundEnabled(on)
},
restoreSnapshot: async (snapshotId: string) => {
const st = get()
const file = await getSaveSlot(st.slot)
@@ -299,6 +311,10 @@ function openState(state: GameState, slot: number): void {
function makeBus(st: GameStore): WorldEventBus {
return {
onLog: (kind, text) => {
if (kind === 'good') sGood()
else if (kind === 'bad') sBad()
else if (kind === 'war') sWar()
else if (kind === 'chronicle') sBell()
const w = st.world
st.addLog({ id: logSeq++, kind, text, year: w?.state.year ?? 0, month: w?.state.month ?? 0 })
},
+111
View File
@@ -1229,6 +1229,105 @@ body {
letter-spacing: 2px;
}
/* ---------- 谱系 ---------- */
.genealogy-row {
display: flex;
gap: 14px;
margin-bottom: 14px;
background: linear-gradient(180deg, rgba(242, 233, 212, 0.035), rgba(242, 233, 212, 0.015));
border: 1px solid rgba(140, 120, 80, 0.22);
border-radius: 3px;
padding: 12px 14px;
}
.genealogy-gen {
writing-mode: vertical-rl;
color: #cdaa63;
letter-spacing: 6px;
font-size: 0.95rem;
border-right: 1px solid rgba(140, 120, 80, 0.25);
padding-right: 10px;
flex-shrink: 0;
}
.genealogy-body {
flex: 1;
display: flex;
flex-direction: column;
gap: 12px;
}
.genealogy-unit {
display: flex;
flex-direction: column;
gap: 6px;
border-left: 3px solid rgba(150, 128, 88, 0.35);
padding-left: 10px;
}
.genealogy-parents {
display: flex;
align-items: center;
gap: 8px;
}
.genealogy-matron {
color: #a09374;
font-size: 0.9rem;
}
.genealogy-drop {
height: 12px;
border-left: 2px solid rgba(150, 128, 88, 0.4);
margin-left: 30px;
}
.genealogy-children,
.genealogy-singles {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.gene-node {
background: linear-gradient(180deg, #f4ecd9, #e7d6b4);
color: #2e2415;
border: 1px solid #b3a075;
border-radius: 3px;
padding: 5px 10px;
min-width: 108px;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.5), inset 0 1px 0 rgba(255, 252, 240, 0.6);
transition: all 0.14s;
}
.gene-node:hover {
border-color: var(--gold);
transform: translateY(-1px);
}
.gene-dead {
filter: saturate(0.4);
opacity: 0.55;
background: linear-gradient(180deg, #d9cfb8, #c8bb9e);
}
.gene-head {
border-color: var(--vermilion);
box-shadow: 0 0 0 1px rgba(169, 59, 46, 0.25), 0 2px 6px rgba(0, 0, 0, 0.5);
}
.gene-name {
display: flex;
align-items: center;
font-size: 0.94rem;
font-weight: 700;
letter-spacing: 1px;
}
.gene-sub {
font-size: 0.75rem;
color: #6d5b3e;
margin-top: 1px;
display: flex;
align-items: center;
flex-wrap: wrap;
}
.gene-node .tag {
font-size: 0.62rem;
padding: 0 4px;
}
.genealogy-gen + .genealogy-body:has(.genealogy-singles:empty) .genealogy-singles {
display: none;
}
/* ---------- 媒人提示 / 年度族簿 ---------- */
.matchmaker-strip {
background: linear-gradient(180deg, rgba(240, 214, 160, 0.12), rgba(240, 214, 160, 0.05));
@@ -1307,3 +1406,15 @@ body {
margin-bottom: 16px;
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.8);
}
/* ---------- 宗祠碑录 ---------- */
.monument-strip {
margin-bottom: 14px;
background: rgba(20, 15, 9, 0.72);
border: 1px solid #33291a;
border-radius: 3px;
padding: 10px 16px;
}
.monument-strip .card-title {
color: #cdaa63;
}