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
+1 -1
View File
@@ -22,7 +22,7 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
```
冒烟会写真实用户数据目录(Linux 下 `~/.config/Electron`),跑完删掉该目录以免脏数据。
无显示环境(WSLg 掉线)会打印 `[SMOKE-NO-DISPLAY]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。
无显示环境(WSLg 掉线)会打印 `[SMOKE-TIMEOUT]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。另注意:`npx electron` 可能拉取**新版** electron 缓存版(与本仓库 33.x 不同),二进制安装以 `node_modules/electron` 为准。
## 环境坑
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.1",
"version": "0.1.2",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
+8
View File
@@ -47,6 +47,14 @@ function createWindow(): void {
if (process.env.SMOKE_TEST) {
const wc = mainWindow.webContents
const smokeWatchdog = setTimeout(() => {
console.log('[SMOKE-TIMEOUT] window never loaded (WSLg/X11 likely down); use vitest for logic regression')
app.exit(2)
}, 20000)
wc.once('did-finish-load', () => {
clearTimeout(smokeWatchdog)
})
void smokeWatchdog
setTimeout(async () => {
try {
const url = wc.getURL()
+4
View File
@@ -6,6 +6,7 @@ import GameScreen from './ui/screens/GameScreen'
import { EventModal } from './ui/components/EventModal'
import { BattleModal } from './ui/components/BattleModal'
import { PaperModal } from './ui/components/PaperModal'
import { sClick } from './ui/sound'
export default function App() {
const screen = useGameStore((s) => s.screen)
@@ -14,6 +15,9 @@ export default function App() {
useEffect(() => {
useGameStore.getState().init()
const clicker = () => sClick()
document.addEventListener('click', clicker, true)
return () => document.removeEventListener('click', clicker, true)
}, [])
return (
+55
View File
@@ -0,0 +1,55 @@
import { Character } from '../types/domain'
import type { World } from '../engine/world'
export interface FamilyUnit {
parents: [Character | undefined, Character | undefined]
children: Character[]
gen: number
}
export interface GenRow {
gen: number
singles: Character[]
units: FamilyUnit[]
}
export function computeGenealogy(w: World): GenRow[] {
const alive = w.aliveMembers()
const byGen = new Map<number, Character[]>()
for (const c of alive) {
const g = byGen.get(c.generation) ?? []
g.push(c)
byGen.set(c.generation, g)
}
const gens = [...byGen.keys()].sort((a, b) => a - b)
const rows: GenRow[] = []
for (const gen of gens) {
const members = byGen.get(gen)!
const units: FamilyUnit[] = []
const used = new Set<string>()
for (const c of members) {
if (used.has(c.id)) continue
const spouse = c.spouseId ? w.state.members[c.spouseId] : undefined
if (spouse && spouse.alive && !used.has(spouse.id)) {
used.add(c.id)
used.add(spouse.id)
const children = [...c.children, ...spouse.children]
.filter((id, i, arr) => arr.indexOf(id) === i)
.map((id) => w.state.members[id])
.filter((ch): ch is Character => !!ch && ch.alive)
.sort((a, b) => a.bornYear - b.bornYear)
units.push({ parents: [c, spouse], children, gen })
}
}
const singles = members.filter((c) => !used.has(c.id))
rows.push({ gen, singles, units })
}
return rows
}
export function countDead(w: World): number {
return Object.values(w.state.members).filter((c) => !c.alive).length
}
+1
View File
@@ -40,6 +40,7 @@ export interface EffectDef {
techniqueChance?: number
artifactChance?: number
addTech?: string
feisheng?: { stay: boolean }
}
export interface EventOptionDef {
+60
View File
@@ -0,0 +1,60 @@
export type PostEffectType = 'expAll' | 'marketIncome' | 'battlePower' | 'meditation' | 'familyRep'
export interface PostDef {
id: string
name: string
icon: string
desc: string
max: number
effect: { type: PostEffectType; value: number }
}
export const POSTS: Record<string, PostDef> = {
head: {
id: 'head',
name: '家主',
icon: '主',
desc: '族纲所系:声望逐年 +1,决策由玩家亲定。',
max: 1,
effect: { type: 'familyRep', value: 1 }
},
elder: {
id: 'elder',
name: '长老',
icon: '长',
desc: '垂教宗族:全族修炼速度 +5%(每名)。',
max: 2,
effect: { type: 'expAll', value: 0.05 }
},
guardian: {
id: 'guardian',
name: '供奉',
icon: '供',
desc: '克敌之锐:宗族战力 +8%(每名)。',
max: 2,
effect: { type: 'battlePower', value: 0.08 }
},
steward: {
id: 'steward',
name: '执事',
icon: '执',
desc: '持家之能:坊市收入 +10%(每名)。',
max: 2,
effect: { type: 'marketIncome', value: 0.1 }
},
master: {
id: 'master',
name: '掌教',
icon: '掌',
desc: '授道之责:闭关子弟修炼 +6%(每名)。',
max: 1,
effect: { type: 'meditation', value: 0.06 }
}
}
export const POST_ORDER = ['head', 'elder', 'guardian', 'steward', 'master']
export function postById(id: string | undefined): PostDef | null {
if (!id) return null
return POSTS[id] ?? null
}
@@ -15,13 +15,20 @@ export function monthlyRate(w: World, c: Character): number {
rate *= 0.5 + c.perception * 0.1
rate *= ROOT_GRADES[c.roots.grade]?.expBonus ?? 0.5
const tech = techniqueById(c.techniqueId)
if (tech && c.realm.major !== 'mortal') rate *= 1 + tech.expBonus
else if (c.realm.major !== 'mortal') rate *= 0.65
if (tech && c.realm.major !== 'mortal') {
rate *= 1 + tech.expBonus + (c.techniqueRank ?? 0) * 0.05
} else if (c.realm.major !== 'mortal') {
rate *= 0.65
}
const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05
rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05
if (c.traits.includes('fengxian')) rate *= 1.3
if (c.state === 'meditation') {
rate *= 1.35
rate *= 1 + w.postBonus('meditation')
const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') {
@@ -41,6 +48,25 @@ export function cultivationTick(w: World): void {
const rate = monthlyRate(w, c)
if (rate <= 0) continue
c.realmProgress = Math.min(100, c.realmProgress + rate)
// 悟道进度:有功法且修为之外,另积一分慧根
if (c.techniqueId && c.realm.major !== 'mortal') {
const rank = c.techniqueRank ?? 0
const gain = rate * (rank === 0 ? 0.5 : rank === 1 ? 0.3 : 0.15)
c.techniqueProgress = Math.min(100, (c.techniqueProgress ?? 0) + gain)
if (c.techniqueProgress >= 100) {
if (rank === 0) {
c.techniqueRank = 1
c.techniqueProgress = 0
w.log('good', `${c.name} 参悟《${techniqueName(c.techniqueId)}》小成,战力精进。`)
w.chronicle('breakthrough', `${c.name} 参悟《${techniqueName(c.techniqueId)}》小成。`, c.id, false)
} else if (rank === 1) {
c.techniqueRank = 2
c.techniqueProgress = 0
w.log('good', `${c.name} 于《${techniqueName(c.techniqueId)}》上再进一层,臻至大成!`)
w.chronicle('breakthrough', `${c.name} 将《${techniqueName(c.techniqueId)}》精修大成。`, c.id, true)
}
}
}
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
@@ -50,6 +76,25 @@ export function cultivationTick(w: World): void {
}
}
function techniqueName(id: string | undefined): string {
const t = techniqueById(id)
return t ? t.name : '无名功法'
}
function closestDisciple(w: World, c: Character): Character | null {
const heirs = c.children
.map((id) => w.state.members[id])
.filter((ch): ch is Character => !!ch?.alive && !!ch.techniqueId)
.sort((a, b) => rankScore(b) - rankScore(a))
if (heirs.length > 0) return heirs[0]
return null
}
function rankScore(c: Character): number {
const order = ['mortal', 'qi', 'foundation', 'core', 'nascent', 'spirit']
return order.indexOf(c.realm.major) * 10 + c.realm.minor
}
export function perAttemptChance(w: World, c: Character): number {
const base = breakthroughBaseChance(c.realm)
const mind = c.mind * 0.008
@@ -73,12 +118,25 @@ export function resolveBreakthrough(w: World, c: Character, boost: number): void
c.realmProgress = 0
if (majorJump) {
c.health = 100
// 悟道传承:大境界圆满者,将心得传于同枝晚辈
const apprentice = closestDisciple(w, c)
if (apprentice) {
const cur = apprentice.techniqueProgress ?? 0
if (cur < 60) {
apprentice.techniqueProgress = Math.max(cur, 60)
w.log('info', `${apprentice.name} 承得 ${c.name} 破境感悟,修行一日千里。`)
}
}
}
const desc = describeRealm(next)
w.chronicle('breakthrough', `${c.name} 突破至【${desc}】。`, c.id, majorJump)
w.log('good', `${c.name} 突破到 ${desc}`)
if (next.major === 'spirit') {
w.chronicle('breakthrough', `华夏震惊:${c.name} 踏入化神之列。`, c.id, true)
if (!w.state.flags['firstSpirit']) {
w.state.flags['firstSpirit'] = w.state.year
w.log('bad', `天地异象:族中第一位化神出世,仙路大门洞开。`)
}
}
} else {
c.health = Math.max(1, c.health - 8 - w.rng.int(0, 10))
@@ -7,6 +7,7 @@ import { MISSIONS } from '../../data/secrets'
import { npcById } from '../../data/npcs'
import { resolveRaid } from './combat'
import { sendMission } from './missions'
import { findInheritor } from '../creation'
const ALL_EVENTS: EventDef[] = [...EVENTS]
@@ -31,6 +32,35 @@ export function dynamicEventFor(id: string): EventDef | undefined {
]
}
}
if (id === 'ev-centennial') {
return {
id,
name: '百年庆典',
category: 'fate',
weight: 0,
once: true,
text: '百年元辰已至:这百年间,苗裔繁衍、香火未断。大宴宾客?还是告天祭祖?',
options: [
{ label: '大开宴席,张灯结彩', hint: '灵石-200,声望+15,全族心境大悦', eff: { res: { stones: -200 }, rep: 15, memberBy: { by: 'inspire', target: 'all', n: 4 } } },
{ label: '设坛告天', hint: '灵石-100,声望+10', eff: { res: { stones: -100 }, rep: 10, flag: { centennial: 'rite' } } },
{ label: '阖家简庆', hint: '声望+5', eff: { rep: 5 } }
]
}
}
if (id === 'ev-feisheng') {
return {
id,
name: '飞升之路',
category: 'fate',
weight: 0,
once: true,
text: '化神之上本为天堑,如今族中已有人触碰仙门。飞升之路敞开:宗族该作何抉择?',
options: [
{ label: '留仙坐镇', hint: '他留世镇族:天赋大盛', eff: { feisheng: { stay: true } } },
{ label: '放仙飞升', hint: '他云游而去寻仙门:宗族蒙荫', eff: { feisheng: { stay: false } } }
]
}
}
return undefined
}
@@ -81,6 +111,18 @@ export function eventRoll(w: World): void {
}
return
}
// 里程碑检测(优先于日常事件)
if (s.year >= 100 && !s.completedEvents.includes('ev-centennial')) {
fire(w, 'ev-centennial')
return
}
const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && s.year - firstSpirit <= 3 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng')
return
}
const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return
@@ -206,6 +248,30 @@ function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
if (eff.addTech) {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
}
if (eff.feisheng) {
const s2 = w.state
const immortal = w
.aliveMembers()
.sort((a, b) => rankPower(w, b) - rankPower(w, a))[0]
if (immortal) {
if (eff.feisheng.stay) {
if (!immortal.traits.includes('fengxian')) immortal.traits.push('fengxian')
w.chronicle('event', `${immortal.name} 谢绝仙门征召,愿镇守此世,护佑宗族万世。`, immortal.id, true)
w.log('good', `${immortal.name} 留世坐镇(仙风入体)。`)
} else {
immortal.alive = false
immortal.deathYear = s2.year
immortal.deathCause = '云游飞升'
s2.family.flag['fengFeiBless'] = true
w.chronicle('event', `${immortal.name} 于月首孤身东去,驾鹤而飞。天地留一缕仙风,庇佑宗族。`, immortal.id, true)
w.log('good', `${immortal.name} 飞升而去,宗族蒙庇。`)
if (s2.family.headId === immortal.id) {
const heir = findInheritor(w)
if (heir) w.assignHead(heir.id, true)
}
}
}
}
if (eff.memberBy) {
const targets = pickMember(w, eff.memberBy)
const by = eff.memberBy.by
@@ -32,7 +32,7 @@ export function productionTick(w: World): void {
parts.push(`灵矿+${v}灵矿`)
}
if (fangshi > 0) {
const v = 55 * fangshi
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome')))
fam.stones += v
parts.push(`坊市+${v}灵石`)
}
+47 -1
View File
@@ -10,6 +10,7 @@ import {
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { POSTS } from '../data/posts'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick, resolveBreakthrough } from './systems/cultivation'
@@ -128,6 +129,7 @@ export class World {
private yearStart(): void {
yearStartMarriage(this)
this.state.family.reputation += this.postBonus('familyRep')
const rep = this.state.family.reputation
const power = this.familyPower()
const report: YearlyReport = {
@@ -164,9 +166,21 @@ export class World {
if (!headId) return
const head = this.memberById(headId)
if (head.alive) return
// 功德碑:宗主薨,勒石纪功
const reignStart = (s.family.flag['reignStart'] as number | undefined) ?? 1
const reignYears = Math.max(1, s.year - reignStart)
const peak = s.family.reputation
const top = this.aliveMembers().length
this.chronicle(
'misc',
`功德碑:先主${head.name}承宗${reignYears}载,宗族声望达「${peak}」、丁口${top}。族人勒石铭功,立于宗祠。`,
head.id,
true
)
const heir = findInheritor(this)
if (heir) {
this.assignHead(heir.id, true)
s.family.flag['reignStart'] = s.year
} else if (this.aliveMembers().length === 0) {
this.gameOver('满门凋零,香火断绝', s.year)
}
@@ -186,6 +200,7 @@ export class World {
}
c.isHead = true
this.state.family.headId = id
this.state.family.flag['reignStart'] = this.state.year
if (!silent) {
this.chronicle('misc', `${c.name} 继任为家主。`, c.id, true)
this.log('info', `${c.name} 继任为家主。`)
@@ -308,9 +323,40 @@ export class World {
s.members[c.id] = c
}
postCount(def: string): number {
return this.aliveMembers().filter((c) => c.post === def).length
}
assignPost(memberId: Id, postId: string | undefined): boolean {
const c = this.memberById(memberId)
if (!c.alive) return false
if (postId === undefined || postId === '') {
c.post = undefined
return true
}
const def = POSTS[postId]
if (!def || def.id === 'head') return false
if (this.postCount(postId) >= def.max) return false
c.post = postId
return true
}
postBonus(type: string): number {
let sum = 0
for (const c of this.aliveMembers()) {
const def = POSTS[c.post ?? '']
if (def && def.effect.type === type) sum += def.effect.value
}
return sum
}
familyPower(): number {
const fam = this.state.family
const bonus = 1 + (fam.buildings['yanwu'] ?? 0) * 0.04 + (fam.buildings['lingshou'] ?? 0) * 0.05
const bonus =
1 +
(fam.buildings['yanwu'] ?? 0) * 0.04 +
(fam.buildings['lingshou'] ?? 0) * 0.05 +
this.postBonus('battlePower')
const top = this.aliveMembers()
.map((c) => combatPowerOf(this, c))
.sort((a, b) => b - a)
+3
View File
@@ -42,6 +42,9 @@ export interface Character {
fortune: number
traits: string[]
techniqueId?: string
techniqueRank?: number
techniqueProgress?: number
post?: string
equipment?: string
state: CharState
health: number
@@ -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;
}
+113 -1
View File
@@ -2,8 +2,11 @@ import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { combatPowerOf } from '../src/renderer/game/engine/systems/combat'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../src/renderer/game/engine/market'
import { resolveBreakthrough } from '../src/renderer/game/engine/systems/cultivation'
import { resolveBreakthrough, monthlyRate } from '../src/renderer/game/engine/systems/cultivation'
import { applyEventChoice } from '../src/renderer/game/engine/systems/events'
import { computeGenealogy } from '../src/renderer/game/core/genealogy'
import { matchesCondPub } from './events.helpers'
import { resetSaveBus, attachLogSink } from './world.helpers'
describe('Combat power', () => {
it('scales with realm', () => {
@@ -153,3 +156,112 @@ describe('Yearly report', () => {
expect(r.power).toBeGreaterThan(0)
})
})
describe('Posts (职事)', () => {
it('assigns up to max and rejects over-cap', () => {
const w = World.create({ seed: 'posts', surname: '徐', familyName: '徐家', motto: 'm', difficulty: 'normal' })
expect(w.assignPost('x3', 'elder')).toBe(true)
expect(w.assignPost('x5', 'elder')).toBe(true)
expect(w.assignPost('x4', 'elder')).toBe(false)
expect(w.postCount('elder')).toBe(2)
})
it('elder boosts cultivation rate', () => {
const w = World.create({ seed: 'posts2', surname: '许', familyName: '许家', motto: 'm', difficulty: 'normal' })
const c = w.state.members['x5']
w.state.members['x3'].state = 'meditation'
c.realm = { major: 'qi', minor: 1 }
const r0 = monthlyRate(w, c)
w.assignPost('x3', 'elder')
w.assignPost('x4', 'elder')
const r1 = monthlyRate(w, c)
expect(r1).toBeGreaterThan(r0)
})
it('steward boosts market income', () => {
const w = World.create({ seed: 'posts3', surname: '邓', familyName: '邓家', motto: 'm', difficulty: 'normal' })
w.state.family.buildings['fangshi'] = 2
w.assignPost('x3', 'steward')
const stones0 = w.state.family.stones
w.advanceMonth()
expect(w.state.family.stones).toBeGreaterThan(stones0 + 110)
})
})
describe('Technique wudao (悟道)', () => {
it('accumulates progress and ranks up talents', () => {
const w = World.create({ seed: 'wudao', surname: '康', familyName: '康家', motto: 'm', difficulty: 'easy' })
const c = w.state.members['x4']
c.realm = { major: 'qi', minor: 1 }
c.realmProgress = 0
c.techniqueProgress = 95
c.techniqueRank = 0
c.techniqueId = 't-houtu'
c.perception = 9
for (let i = 0; i < 30; i++) w.advanceMonth()
expect(c.techniqueRank ?? 0).toBeGreaterThanOrEqual(1)
})
it('carries wudao to a disciple on major jump', () => {
const w = World.create({ seed: 'wd2', surname: '封', familyName: '封家', motto: 'm', difficulty: 'easy' })
const c = w.state.members['x1']
c.realm = { major: 'qi', minor: 9 }
c.realmProgress = 100
c.mind = 9
c.perception = 9
const child = w.state.members['x3']
child.techniqueId = 't-houtu'
child.techniqueProgress = 10
child.realm = { major: 'qi', minor: 2 }
resolveBreakthrough(w, c, 0.5)
expect(c.realm.major).toBe('foundation')
expect(child.techniqueProgress).toBeGreaterThanOrEqual(60)
})
})
describe('Monument & milestones', () => {
it('writes monument on head death, inherits reign', () => {
const w = World.create({ seed: 'mon', surname: '侯', familyName: '侯家', motto: 'm', difficulty: 'normal' })
const head = w.state.members['x1']
head.alive = false
head.deathYear = w.state.year
w.advanceMonth()
const monument = w.state.chronicle.find((e) => e.text.startsWith('功德碑'))
expect(monument).toBeTruthy()
expect(w.state.family.headId).toBe('x3')
expect(w.state.family.flag['reignStart']).toBe(w.state.year)
})
it('triggers centennial once at year 100', () => {
const w = World.create({ seed: 'cen', surname: '欧阳', familyName: '欧阳家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
w.state.year = 99
w.state.month = 12
for (let i = 0; i < 3; i++) w.advanceMonth()
expect(w.state.pendingEvent).toBe('ev-centennial')
})
it('feisheng effect grants trail or ascends', () => {
const w = World.create({ seed: 'fs', surname: '皇甫', familyName: '皇甫家', motto: 'm', difficulty: 'normal' })
const c = w.state.members['x4']
c.realm = { major: 'spirit', minor: 1 }
applyEventChoice(w, 'ev-feisheng', 0)
expect(c.traits).toContain('fengxian')
})
})
describe('Genealogy', () => {
it('builds rows per generation with couple units', () => {
const w = World.create({ seed: 'gene', surname: '惠', familyName: '惠家', motto: 'm', difficulty: 'normal' })
const rows = computeGenealogy(w)
expect(rows.length).toBeGreaterThanOrEqual(2)
const second = rows.find((r) => r.gen === 2)
expect(second).toBeTruthy()
const units = second!.units
// x1/x2 couple should appear in gen1
const g1 = rows.find((r) => r.gen === 1)!
expect(g1.units.some((u) => u.parents[0]?.id === 'x1' || u.parents[1]?.id === 'x1')).toBe(true)
expect(units).toBeTruthy()
})
})