diff --git a/AGENTS.md b/AGENTS.md index ce49b77..9a56aa7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` 为准。 ## 环境坑 diff --git a/package.json b/package.json index 46f801e..57ed141 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/main/index.ts b/src/main/index.ts index 19cfc1a..7dc65c9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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() diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 2932509..5728af8 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -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 ( diff --git a/src/renderer/game/core/genealogy.ts b/src/renderer/game/core/genealogy.ts new file mode 100644 index 0000000..07087b7 --- /dev/null +++ b/src/renderer/game/core/genealogy.ts @@ -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() + 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() + + 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 +} diff --git a/src/renderer/game/data/events.ts b/src/renderer/game/data/events.ts index 878a732..5007b7a 100644 --- a/src/renderer/game/data/events.ts +++ b/src/renderer/game/data/events.ts @@ -40,6 +40,7 @@ export interface EffectDef { techniqueChance?: number artifactChance?: number addTech?: string + feisheng?: { stay: boolean } } export interface EventOptionDef { diff --git a/src/renderer/game/data/posts.ts b/src/renderer/game/data/posts.ts new file mode 100644 index 0000000..5a7e710 --- /dev/null +++ b/src/renderer/game/data/posts.ts @@ -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 = { + 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 +} diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index 40c4794..5ec7f3d 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -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)) diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index 637b82a..e41ef4b 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -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 diff --git a/src/renderer/game/engine/systems/production.ts b/src/renderer/game/engine/systems/production.ts index 991832d..970ef96 100644 --- a/src/renderer/game/engine/systems/production.ts +++ b/src/renderer/game/engine/systems/production.ts @@ -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}灵石`) } diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 370fa14..47acf65 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -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) diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 322c8fe..84f295d 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -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 diff --git a/src/renderer/ui/components/MemberCard.tsx b/src/renderer/ui/components/MemberCard.tsx index a6ecdad..9d10d02 100644 --- a/src/renderer/ui/components/MemberCard.tsx +++ b/src/renderer/ui/components/MemberCard.tsx @@ -52,6 +52,11 @@ export function MemberCard({ c }: { c: Character }) { ) } +function postLabel(c: Character): string { + const map: Record = { head: '家主', elder: '长老', guardian: '供奉', steward: '执事', master: '掌教' } + return map[c.post ?? ''] ?? '' +} + const C_STATE: Record = { idle: '', meditation: 'good', diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index 6b5ac8d..0224262 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -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 }) { )} + {member.alive && ( +
+
职事(族中分工)
+
+ + {POST_ORDER.filter((pid) => pid !== 'head').map((pid) => { + const def = POSTS[pid] + const maxed = w.postCount(pid) >= def.max && member.post !== pid + return ( + + ) + })} +
+
+ )} + {member.alive && w.canMarry(member.id) && (
指婚(寻配族内共居者,远亲无碍)
diff --git a/src/renderer/ui/panels/GenealogyPanel.tsx b/src/renderer/ui/panels/GenealogyPanel.tsx new file mode 100644 index 0000000..96bdb37 --- /dev/null +++ b/src/renderer/ui/panels/GenealogyPanel.tsx @@ -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 ( +
+
+ 宗族谱系:世代分带,夫妻同卷,子孙列后。点任一名字可查详情;染灰者已下世,功绩铭记。 +
+
+ + +
+ + {!deceasedTab && + rows.map((row) => ( +
+
第{row.gen}代
+
+ {row.units.map((unit, i) => ( +
+
+ {unit.parents[0] && } +
×
+ {unit.parents[1] && } +
+ {unit.children.length > 0 && ( + <> +
+
+ {unit.children.map((ch) => ( + + ))} +
+ + )} +
+ ))} + {row.singles.length > 0 && ( +
+ {row.singles.map((c) => ( + + ))} +
+ )} + {row.units.length + row.singles.length === 0 &&
此代凋零
} +
+
+ ))} + + {deceasedTab && + deceased.map((c) => ( +
+ + 殁于{c.deathYear}年 + {c.name} · {c.gender === 'male' ? '男' : '女'} · 第{c.generation}代 + {c.deathCause ?? '寿终'} + + +
+ ))} + + {selected && } +
+ ) +} + +function GeneNode({ c, w }: { c: Character; w: NonNullable['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 ( +
setSelected(c.id)} + title={`${c.name} · ${describeRealm(c.realm)} · ${w.ageOf(c)}岁`} + > +
+ {c.name} + {isHead && } + {post && {post}} + {!c.alive && } +
+
+ {c.alive ? `${describeRealm(c.realm)} · ${w.ageOf(c)}岁` : `${c.deathCause ?? '寿终'}${c.deathYear ? `(${c.deathYear}年)` : ''}`} + {rank > 0 && 悟{rank}} +
+
+ ) +} + +const POST_NAME: Record = { + head: '家主', + elder: '长老', + guardian: '供奉', + steward: '执事', + master: '掌教' +} + +export type { FamilyState } diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 1b099cd..7c1aec7 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -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() { 刷新存档槽
+
+ 音效(合成音:鼓点·钟鸣·纸韵) + +
暂停时间 diff --git a/src/renderer/ui/panels/TerritoryPanel.tsx b/src/renderer/ui/panels/TerritoryPanel.tsx index fac19f2..70d017d 100644 --- a/src/renderer/ui/panels/TerritoryPanel.tsx +++ b/src/renderer/ui/panels/TerritoryPanel.tsx @@ -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 ( +
+
宗祠碑录
+ {monuments.slice(0, 4).map((m) => ( +
+ {m.year}年 + {m.text.slice(3)} +
+ ))} +
+ ) +} + function itemLabel(k: string): string { const map: Record = { stone: '灵石', diff --git a/src/renderer/ui/screens/GameScreen.tsx b/src/renderer/ui/screens/GameScreen.tsx index 0e21f20..02817cc 100644 --- a/src/renderer/ui/screens/GameScreen.tsx +++ b/src/renderer/ui/screens/GameScreen.tsx @@ -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() {
{panel === 'family' && } + {panel === 'genealogy' && } {panel === 'territory' && } {panel === 'market' && } {panel === 'diplomacy' && } diff --git a/src/renderer/ui/sound.ts b/src/renderer/ui/sound.ts new file mode 100644 index 0000000..ddc0dea --- /dev/null +++ b/src/renderer/ui/sound.ts @@ -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) +} diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 0cf4b60..9306918 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -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 go: (screen: Screen) => void @@ -54,6 +56,7 @@ export interface GameStore { refreshSnapshots: () => Promise onYearPaper: (report: YearlyReport) => void closePaper: () => void + toggleSound: () => void } const LOG_CAP = 260 @@ -78,6 +81,7 @@ export const useGameStore = create((set, get) => ({ gameOverReason: undefined, paperReport: undefined, snapshots: [], + soundOn: true, init: async () => { const manager = getSlotManager() @@ -145,6 +149,7 @@ export const useGameStore = create((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((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 }) }, diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index 55ad3fb..3393d61 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -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; +} diff --git a/tests/systems.test.ts b/tests/systems.test.ts index 3778b59..fcdba18 100644 --- a/tests/systems.test.ts +++ b/tests/systems.test.ts @@ -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() + }) +})