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
+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