diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..65392ee --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,663 @@ +# 轮回录 - 游戏设计文档 + +## 一、游戏概述 + +**类型**: Rogue-lite + Incremental RPG +**核心体验**: 在无限轮回中积累经验与记忆,逐步解锁世界真相,最终对抗隐藏的终极威胁。 +**目标玩家**: 喜欢长期成长、策略试错、叙事探索的玩家。 +**单局时长**: 一局人生 5~15 分钟(加速模式下),完整通关需 50~100+ 世。 + +--- + +## 二、核心循环(Core Loop) + +``` +出生(随机出身 + 词条池抽取) + ↓ +日常推进(文本事件自动流过 / 选择事件 10秒倒计时) + ↓ +积累经验 → 职业晋升 → 解锁能力 + ↓ +死亡(自然 / 外敌 / 事件风险) + ↓ +结算(元经验 + 新词条解锁 + 历史对比) + ↓ +重生(保留元经验 / 记忆 / 词条池,外敌进度部分继承) + ↓ +(循环) +``` + +**关键张力**:每世都是不可预测的(随机出身、词条、事件),但跨世成长是确定可感的(元经验压缩前期、词条池扩大、记忆触发新选项)。 + +--- + +## 三、系统详解 + +### 3.1 时间系统 + +| 项目 | 设计 | +|---|---| +| 最小单位 | 天 | +| 显示单位 | 天 / 年 / 年龄 | +| 加速档位 | 1x(1天/秒)、10x、100x、MAX(瞬间过完一年) | +| 加速时显示 | 文本事件快速滚动(日志流),选择事件暂停等玩家 | +| 存档 | 每年自动存档 + 手动存档 | + +**一天内的流程**: +1. 推进日期 +2. 检查固定事件触发 +3. 从事件池随机抽取文本事件(概率控制) +4. 检查是否有选择事件触发 +5. 推进经验、属性自然增长 +6. 推进外敌进度 +7. 检查死亡条件 + +### 3.2 事件系统 + +#### 事件类型 + +| 类型 | 触发方式 | 玩家交互 | 占比 | +|---|---|---|---| +| **文本事件** | 概率触发 / NPC事件线 | 无,自动流过 | 80% | +| **选择事件** | 固定时间点 / 条件触发 | 10秒倒计时,不选默认 | 15% | +| **重大事件** | 事件链终点 / 外敌阈值 | 必须选择 | 5% | + +#### 事件结构 + +```javascript +{ + id: 'event_001', + title: '山中奇遇', + // 触发条件 + condition: (state) => state.age >= 5 && state.age <= 10 + && !state.worldFlags.met_old_taoist, + // 概率权重(同条件事件竞争) + weight: 10, + // 是否为选择事件 + isChoice: true, + // 默认选项索引(倒计时结束时自动选) + defaultChoice: 0, + // 选项 + choices: [ + { + text: '跟随老者', + // 硬门槛判定 + requirement: (state) => state.stats.wisdom >= 3, + // 效果 + effect: (state) => { + state.worldFlags.met_old_taoist = true; + return { unlockCareer: 'cultivation', exp: { cultivation: 100 } }; + } + }, + { + text: '转身离开', + effect: (state) => ({ exp: { hunter: 20 } }) + } + ], + // 事件链标记 + flags: { saw_mountain_path: true }, + // 后置事件(本事件触发后,后置事件进入候选池) + unlocks: ['event_002', 'event_003'] +} +``` + +#### 标记系统(防逻辑错误) + +```javascript +worldFlags: { + 'npc.zhangsan.alive': true, // NPC生死 + 'npc.zhangsan.married': false, // NPC婚姻状态 + 'event.mountain_discovery.happened': true, // 事件已发生 + 'enemy.north.barbarian_invasion': false, // 外敌入侵状态 +} +``` + +任何事件在修改标记前必须检查依赖标记。例如:"张三死了"的事件触发后,`npc.zhangsan.alive` 设为 false,后续所有涉及张三的事件必须检查此标记。 + +### 3.3 职业系统 + +#### 职业结构 + +职业呈**线性晋升链**,无"转换"概念。初始只有少数基础职业,等级达标后解锁后续职业。 + +**示例职业链**: + +| 层级 | 职业示例 | 类型 | 晋升条件 | +|---|---|---|---| +| 基础 | 混混、磨刀学徒、农夫、书童 | 短线 | 无 | +| 进阶 | 红棍、打刀下手、猎户、秀才 | 短线 | 基础职业 Lv.5 | +| 中阶 | 香主、锻刀师傅、商人、举人 | 中线 | 进阶职业 Lv.10 | +| 高阶 | 堂主、炼刀匠人、大儒、进士 | 中线 | 中阶职业 Lv.20 | +| 顶级 | 舵主、铸剑师、一代宗师、宰相 | 长线 | 高阶职业 Lv.30 | +| 终极 | 帮主、仙匠、圣贤、帝王 | 极长线 | 顶级职业 Lv.50 | + +**修仙者**不是独立职业,而是多条路线的终极交汇点。例如: +- 锻刀师傅 → 炼刀匠人 → 铸剑师 → (悟剑道)→ 剑修 +- 大儒 → (悟道)→ 儒仙 +- 红棍 → (武道通神)→ 武仙 + +#### 职业属性 + +```javascript +{ + id: 'hoodlum', + name: '混混', + type: 'short', // short / medium / long / ultimate + maxLevel: 10, + baseExp: 20, + expCurve: 1.15, // 每级经验需求增长 + // 自然成长(每天自动获得的经验) + dailyExp: 5, + // 晋升链 + promotesTo: 'red_stick', // Lv.10 解锁 + promoteRequirement: { level: 10 }, + // 能力解锁(随等级解锁) + abilities: [ + { level: 3, id: 'street_fight', name: '街头斗殴', effect: '战斗事件成功率+10%' }, + { level: 7, id: 'intimidate', name: '威吓', effect: 'NPC事件中威慑选项可用' }, + ] +} +``` + +#### 多职业并行 + +玩家可以同时拥有多个职业,每个职业独立计算等级和经验。例如:白天当农夫(farmer Lv.5),晚上混帮派(hoodlum Lv.3)。 + +### 3.4 成长系统 + +#### 三层成长 + +| 层级 | 名称 | 跨轮保留 | 作用 | 示例 | +|---|---|---|---|---| +| **L1** | 元经验 | 是 | 压缩前期成长曲线 | 剑术元经验 500 → 前 50 级 +80% 速度 | +| **L2** | 能力 | 是 | 解锁新技能/选项 | 剑术 Lv.30 留下"剑气感悟",下世 Lv.10 即可用剑气 | +| **L3** | 词条 | 是 | 重生时的随机特性池 | 解锁"孤儿"词条,重生时可能随机到 | + +#### 元经验公式 + +```javascript +// 元经验加成(对数衰减,永不归零) +function metaBoost(careerId, currentLevel, metaExp) { + return Math.log(1 + metaExp / 100) * Math.exp(-currentLevel / 80); +} + +// 总经验倍率 +function totalExpMultiplier(state, careerId) { + const career = state.careers[careerId]; + const meta = state.metaExp[careerId] || 0; + const level = career?.level || 0; + + let multiplier = 1; + + // 元经验加成 + multiplier += metaBoost(careerId, level, meta); + + // 天赋加成 + for (const t of state.talents) { + if (t.effect.target === careerId || t.effect.target === 'all') { + multiplier += t.effect.value; + } + } + + // 记忆加成 + for (const m of state.memories) { + if (m.effect?.target === careerId) { + multiplier += m.effect.value; + } + } + + return multiplier; +} +``` + +#### 能力链 + +不是"境界"概念,而是**具体技能的解锁**。例如: + +**剑术能力链**: +- Lv.1~9:基础剑法(数值成长) +- Lv.10:解锁【剑气】(攻击范围扩大) +- Lv.15:解锁【御剑】(移动速度提升) +- Lv.25:解锁【万剑归宗】(群体攻击) +- Lv.40:解锁【人剑合一】(攻击无视防御) + +**跨世传承**:某一世把剑术练到 Lv.30,死亡后留下"剑气感悟"记忆。下一世再练剑术时,Lv.10 自动解锁【剑气】(不需要重新练到 Lv.25)。 + +### 3.5 词条系统 + +#### 词条定义 + +词条是重生时随机抽取的"身份/天赋/携带物",影响整局游戏。 + +```javascript +{ + id: 'orphan', + name: '孤儿', + type: 'identity', // identity / talent / item / curse + desc: '无父无母,街头生存经验丰富', + effect: { target: 'hoodlum', expRate: 0.3, body: 1 }, + // 解锁条件 + unlockCondition: (history) => history.some(h => h.family < -1) +} +``` + +#### 词条类型 + +| 类型 | 示例 | 效果 | +|---|---|---| +| **身份** | 孤儿、皇室后裔、商贾之子 | 影响出身、初始事件池 | +| **天赋** | 剑骨、灵根、过目不忘 | 直接影响某职业成长 | +| **携带物** | 祖传玉佩、神秘种子、旧日记 | 触发特定事件链 | +| **诅咒** | 短命、招灾、孤星 | 负面效果,但可能解锁隐藏路线 | + +#### 解锁机制 + +- **等级解锁**:剑术 Lv.30 → 解锁"剑气"词条 +- **事件解锁**:完成"梦中悟道"事件链 → 解锁"悟道"词条 +- **条件解锁**:活到 100 岁 → 解锁"长寿"词条;被毒杀 → 解锁"毒物警觉"词条 +- **成就解锁**:累计 10 世当混混 → 解锁"江湖中人"词条 + +#### 重生抽取 + +每世重生时,从已解锁词条池中随机抽取 3~5 个。词条池越大,组合越丰富。 + +### 3.6 NPC系统 + +#### NPC结构 + +```javascript +{ + id: 'zhangsan', + name: '张三', + // NPC有自己的事件线 + eventLine: [ + { day: 100, event: 'zhangsan_marriage', condition: (state) => state.worldFlags.npc.zhangsan.alive }, + { day: 500, event: 'zhangsan_death', condition: (state) => state.worldFlags.npc.zhangsan.married === false }, + ], + // 与玩家的关系 + relation: { + trust: 0, // -100 ~ 100 + favor: 0, // -100 ~ 100 + }, + // NPC可以给玩家东西 + gifts: [ + { condition: (state) => state.npcs.zhangsan.favor > 50, item: 'old_sword' } + ] +} +``` + +#### NPC事件对玩家的影响 + +| 影响类型 | 示例 | +|---|---| +| **纯文本** | "张三今天娶了李四"(仅信息) | +| **关系影响** | "张三向你求助"(接受+信任,拒绝-信任) | +| **赠送物品** | "张三送你一把旧剑"(激活词条或加成) | +| **世界事件前置** | "张三发现了秘境入口"(开启新事件链) | +| **连锁反应** | "张三死了,他的仇家找上了你" | + +### 3.7 外敌系统 + +#### 三相威胁 + +| 相 | 名称 | 增长条件 | 对抗方式 | +|---|---|---|---| +| **军事** | 北方蛮族 | 王朝不稳时加速 | 武道/修仙等级 | +| **精神** | 域外天魔 | 修仙者死亡时泄漏 | 悟性/意志力 | +| **政治** | 王朝崩坏 | 长期无人治国 | 权势/民心 | + +#### 三相联动 + +``` +王朝崩坏 +10% → 蛮族入侵加速 +20% +蛮族入侵 +10% → 民心下降 → 王朝崩坏加速 +15% +天魔侵蚀 +10% → 修仙者恐慌 → 更多修仙者死亡 → 天魔加速 +``` + +#### 隐藏的第四相 + +当三相都稳定(均 < 20%)时,触发"真相浮现"事件链: +- 三相只是表象,真正的威胁是"轮回本身" +- 玩家的无限轮回正在撕裂世界 +- 最终目标:找到终止轮回或拯救世界的方法 + +#### 跨轮影响 + +- 某一世成功击退蛮族 → 下一世蛮族起点降低 +- 某一世天魔侵蚀严重 → 下一世天魔起点更高 +- 三相整体进度跨轮部分继承(如保留 30%) + +### 3.8 存档系统 + +| 类型 | 触发方式 | 保存内容 | +|---|---|---| +| **自动存档** | 每年年末 | 完整状态 | +| **手动存档** | 玩家主动 | 完整状态 | +| **死亡存档** | 死亡瞬间 | 本世完整记录(用于历史对比) | + +#### 存档结构 + +```javascript +{ + version: '0.1', + slot: 1, + timestamp: Date.now(), + state: { /* 完整游戏状态 */ }, + history: [ /* 往世记录 */ ] +} +``` + +### 3.9 死亡结算 + +#### 结算内容 + +1. **本世回顾** + - 活了多久(天/年) + - 死因 + - 职业成就(各职业最高等级) + - 走过的事件链 + - 解锁的词条 + - 对世界的影响(外敌变化) + +2. **历史对比** + - 这一世 vs 上一世:多活了几年? + - 这一世 vs 历史最佳:职业等级差距? + - 累计元经验增长曲线 + +3. **元经验结算** + - 各职业经验 → 元经验(转化率 10%) + +4. **新词条解锁提示** + - "解锁新词条:短命(被毒杀3次)" + +--- + +## 四、数值设计 + +### 4.1 经验曲线 + +```javascript +// 升级所需经验 +function expToNextLevel(careerConfig, currentLevel) { + return Math.floor(careerConfig.baseExp * Math.pow(careerConfig.expCurve, currentLevel)); +} + +// 示例:混混(baseExp=20, curve=1.15) +// Lv.1 → 2: 23 +// Lv.5 → 6: 40 +// Lv.9 → 10: 71 + +// 示例:修仙者(baseExp=100, curve=1.25) +// Lv.1 → 2: 125 +// Lv.10 → 11: 931 +// Lv.50 → 51: 约 60万 +// Lv.99 → 100: 约 3万亿 +``` + +### 4.2 元经验成长预期 + +| 轮回 | 单世主要成就 | 累计元经验(剑术) | 前30级加成 | +|---|---|---|---| +| 1 | 剑术 Lv.15 | 150 | +50% | +| 2 | 剑术 Lv.25 | 500 | +90% | +| 3 | 剑术 Lv.35 | 1200 | +120% | +| 5 | 剑术 Lv.50 | 4000 | +160% | +| 10 | 剑术 Lv.80 | 20000 | +200% | +| 20 | 剑术 Lv.100 | 80000 | +240% | + +注:加成是"速度倍率",不是"直接加等级"。元经验让前期飞快,但瓶颈期(高等级)仍然需要多轮积累。 + +### 4.3 死亡概率 + +| 年龄 | 自然死亡概率/年 | 备注 | +|---|---|---| +| 0~50 | 0% | | +| 51~60 | 2% | | +| 61~70 | 8% | | +| 71~80 | 20% | | +| 81~90 | 45% | | +| 91~100 | 80% | | +| 100+ | 100% | 必然死亡 | + +外敌死亡:三相任一达到 100% 时,每年 40% 概率直接死亡。 + +### 4.4 事件密度 + +| 年龄段 | 文本事件/年 | 选择事件 | 重大事件 | +|---|---|---|---| +| 幼年(0~6) | 20~30 | 2~3 | 0 | +| 少年(7~15) | 10~15 | 3~5 | 1 | +| 青年(16~25) | 5~8 | 2~3 | 1 | +| 壮年(26~40) | 3~5 | 2~3 | 1 | +| 中年(41~60) | 2~4 | 1~2 | 1 | +| 老年(61+) | 5~10 | 2~3 | 1 | + +--- + +## 五、架构设计 + +### 5.1 模块结构 + +``` +src/ + engine/ + state.js # 游戏状态管理 + time.js # 时间推进、加速控制 + eventEngine.js # 事件调度器(条件匹配 → 加权随机 → 执行) + expSystem.js # 经验计算、升级、能力解锁 + death.js # 死亡判定、轮回结算 + saveSystem.js # 存档/读档 + flagSystem.js # 世界标记管理 + content/ + events/ + index.js # 事件注册中心 + child.js # 幼年事件 + youth.js # 少年事件 + adult.js # 成年事件 + common.js # 通用日常事件 + chains/ # 事件链定义 + careers/ + index.js # 职业注册中心 + short/ # 短线职业 + medium/ # 中线职业 + long/ # 长线职业 + ultimate/ # 终极职业 + talents/ + index.js # 词条注册中心 + npcs/ + index.js # NPC注册中心 + ui/ + renderer.js # 渲染器 + eventPanel.js # 事件面板 + logStream.js # 日志流 + careerPanel.js # 职业面板 + deathScreen.js # 死亡结算界面 + main.js # 入口 +``` + +### 5.2 数据流 + +``` +用户交互(点击/加速/选择) + ↓ +UI Layer → 调用 Engine API + ↓ +Time.tick() / EventEngine.trigger() / ExpSystem.gain() + ↓ +修改 State + ↓ +State 变更通知 UI + ↓ +UI 重新渲染 +``` + +### 5.3 事件引擎核心逻辑 + +```javascript +class EventEngine { + constructor() { + this.eventPool = []; // 所有注册的事件 + this.activeChains = []; // 当前活跃的事件链 + } + + // 注册事件 + register(eventDef) { + this.eventPool.push(eventDef); + } + + // 每tick调用 + tick(state) { + // 1. 筛选满足条件的事件 + const candidates = this.eventPool.filter(e => + e.condition(state) && + !state.triggeredEvents.has(e.id) && + this.checkPrerequisites(e, state) + ); + + // 2. 按权重随机选择 + const event = this.weightedRandom(candidates); + if (!event) return null; + + // 3. 标记已触发 + state.triggeredEvents.add(event.id); + + // 4. 解锁后置事件 + if (event.unlocks) { + event.unlocks.forEach(id => this.activateChain(id)); + } + + return event; + } + + checkPrerequisites(event, state) { + if (!event.prerequisites) return true; + return event.prerequisites.every(flag => { + const negate = flag.startsWith('!'); + const key = negate ? flag.slice(1) : flag; + const value = this.getFlag(state, key); + return negate ? !value : !!value; + }); + } +} +``` + +--- + +## 六、UI设计 + +### 6.1 主界面布局 + +``` +┌─────────────────────────────────────────────────────┐ +│ [世数] 第42世 │ [时间] 第15年 第128天 │ [年龄] 15岁 │ +├─────────────────────────────────────────────────────┤ +│ 加速: [1x] [10x] [100x] [MAX] │ [暂停] [存档] │ +├─────────────────────────────────────────────────────┤ +│ │ +│ 【事件面板】 │ +│ (选择事件时显示倒计时) │ +│ │ +├─────────────────────────────────────────────────────┤ +│ 【日志流】(文本事件滚动显示) │ +│ [128天] 张三今天娶了李四 │ +│ [129天] 你在街头遇到了一个老乞丐... │ +│ [130天] 【选择事件】山中奇遇(倒计时: 7秒) │ +│ │ +├─────────────────────────────────────────────────────┤ +│ 词条: [孤儿] [剑骨] [神秘种子] │ +├─────────────────────────────────────────────────────┤ +│ 属性: 体质12 悟性8 魅力5 气运7 │ +├─────────────────────────────────────────────────────┤ +│ 职业: │ +│ 混混 Lv.7 [=====> ] 红棍(解锁条件: Lv.10) │ +│ 磨刀学徒 Lv.3 [==> ] │ +├─────────────────────────────────────────────────────┤ +│ 外敌: 蛮族35% │ 天魔22% │ 崩坏18% │ +├─────────────────────────────────────────────────────┤ +│ 记忆: [剑气感悟] [毒物警觉] │ +│ 元经验: 剑术+3200 混混+800 │ +└─────────────────────────────────────────────────────┘ +``` + +### 6.2 死亡结算界面 + +``` +┌─────────────────────────────────────────────────────┐ +│ 【身死道消】 │ +│ │ +│ 你活了 67 年 128 天 │ +│ 死因:寿终正寝 │ +│ │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 本世成就: │ +│ • 混混 Lv.10 → 红棍(晋升!) │ +│ • 剑术 Lv.35 │ +│ • 走过事件链:山中奇遇 → 老者传道 → 宗门测试 │ +│ • 解锁词条:剑气、江湖中人 │ +│ │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 历史对比: │ +│ 本世 vs 上世:多活了 12 年 ✓ │ +│ 本世 vs 最佳:剑术差距 -15 级 │ +│ 累计元经验:剑术 3200 (+800 本世) │ +│ │ +│ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │ +│ 新解锁词条:长寿(活到80岁解锁) │ +│ │ +│ [进入轮回] │ +└─────────────────────────────────────────────────────┘ +``` + +--- + +## 七、内容规划(MVP阶段) + +### 7.1 MVP职业(10个) + +**基础职业(4个)**: +- 混混(短线,10级) +- 磨刀学徒(短线,10级) +- 农夫(短线,10级) +- 书童(短线,10级) + +**进阶职业(4个)**: +- 红棍(混混 Lv.10 解锁,短线,15级) +- 打刀下手(磨刀学徒 Lv.10 解锁,短线,15级) +- 猎户(农夫 Lv.10 解锁,短线,15级) +- 秀才(书童 Lv.10 解锁,中线,30级) + +**高阶职业(2个)**: +- 香主(红棍 Lv.15 解锁,中线,30级) +- 剑修(打刀下手 Lv.15 解锁,长线,50级) + +### 7.2 MVP事件(30个) + +- 幼年事件:5个 +- 少年事件:5个 +- 成年事件:10个 +- 通用日常事件:10个 + +### 7.3 MVP词条(20个) + +- 身份词条:5个 +- 天赋词条:8个 +- 携带物词条:4个 +- 诅咒词条:3个 + +### 7.4 MVP NPC(5个) + +- 老乞丐(触发侠客路线) +- 道士(触发修仙路线) +- 张三(普通村民,有完整事件线) +- 李四(商人,可交易/赠送) +- 王五(敌对NPC,可能追杀玩家) + +--- + +## 八、后续扩展方向 + +1. **多人/异步交互**:可以看到其他玩家的轮回记录,学习他们的选择 +2. **MOD系统**:允许玩家自定义事件、职业、词条 +3. **成就系统**:Steam/平台成就集成 +4. **可视化家族树**:显示多世的血缘/师承关系 +5. **AI生成事件**:用LLM根据玩家历史生成个性化事件 diff --git a/DESIGN_REVIEW.md b/DESIGN_REVIEW.md new file mode 100644 index 0000000..17cf21d --- /dev/null +++ b/DESIGN_REVIEW.md @@ -0,0 +1,341 @@ +# 轮回录 - 游戏理论审核报告 + +## 审核方法 +- MDA 框架(Mechanics-Dynamics-Aesthetics) +- 心流理论(Flow Channel) +- 斯金纳箱 / 可变奖励系统 +- 反馈密度分析 +- 长期留存模型 +- 成瘾机制审计 + +--- + +## 一、MDA 框架分析 + +### 1.1 Mechanics(机制层) + +**当前设计的机制清单**: + +| 机制 | 深度 | 风险等级 | +|---|---|---| +| 按天推进 + 加速 | 中等 | 低 | +| 事件池(条件匹配 + 加权随机) | 高 | 中 | +| 标记系统(防逻辑错误) | 高 | 低 | +| 职业晋升链(解锁制) | 高 | 中 | +| 元经验(对数加成) | 中等 | 低 | +| 能力链(等级解锁技能) | 高 | 中 | +| 词条系统(随机特性池) | 高 | **高** | +| NPC事件线 | 高 | **高** | +| 三相外敌 + 隐藏第四相 | 高 | 中 | +| 10秒选择倒计时 | 低 | 低 | +| 死亡结算 + 历史对比 | 中等 | 低 | + +**机制层风险评估**: + +**词条系统(风险:高)** +- 20个词条对于MVP来说数量偏少,重生3-4次后玩家就会感到"又在抽同样的词条" +- 建议MVP至少40-60个词条,或引入"词条组合效果"增加多样性 + +**NPC系统(风险:高)** +- 5个NPC × 每人一个事件线 = 极浅的叙事网络 +- 如果NPC只是"背景板+偶尔送礼",玩家很快就会无视他们 +- 建议至少有一个"深度NPC"(比如老乞丐),贯穿多世,每次遇到都有新对话 + +### 1.2 Dynamics(动态层) + +**预期的玩家行为模式**: + +``` +早期(1-10世):探索期 +├── 行为:尝试不同路线,收集词条,摸清事件规律 +├── 反馈:高频解锁(新职业、新词条、新事件) +└── 风险:如果前期解锁太慢,玩家会在第3-4世就放弃 + +中期(11-30世):优化期 +├── 行为:针对特定路线优化,追求历史最佳记录 +├── 反馈:元经验带来的明显加速,"这一世前期快多了" +└── 风险:如果优化空间太小(路线太线性),玩家会感到"换汤不换药" + +后期(31-100世):终局期 +├── 行为:对抗三相外敌,解锁隐藏第四相 +├── 反馈:世界规则改变(道统),"我的门派存在于这个世界" +└── 风险:如果终局目标太遥远且没有中间里程碑,玩家会迷失 +``` + +**动态层问题识别**: + +**问题1:探索期的"首因效应"陷阱** + +MVP只有10个职业、30个事件。玩家在第3-4世就会看到重复内容。心理学中的**首因效应**告诉我们:玩家对前5小时的体验印象最深。如果前5小时都在重复,留存率会暴跌。 + +**修复建议**: +- 前3世强制走不同路线(通过词条权重调整) +- 引入"首次遭遇加成":第一次看到某个事件时奖励翻倍 + +**问题2:优化期的"局部最优"困境** + +玩家可能会发现某条路线(比如混混→红棍→香主)性价比最高,然后每世都走同一条路线。这违背了"无限试错"的设计目标。 + +**修复建议**: +- 引入"路线疲劳":同一职业连走3世,元经验收益递减 +- 引入"跨界加成":同时练两个职业,解锁combo技能 + +**问题3:终局期的"空窗期"焦虑** + +从第31世到解锁第四相,可能需要20-30世的纯积累。这段时间玩家没有明确的短期目标。 + +**修复建议**: +- 每10世设置一个"纪元事件"(如"第10世:天魔第一次入侵") +- 引入"世界进度可视化":让玩家看到三相的具体变化 + +### 1.3 Aesthetics(体验层) + +**设计意图的体验**: + +| 体验 | 实现程度 | 评价 | +|---|---|---| +| **发现感**(Sense of Discovery) | ★★★★☆ | 随机词条 + 事件链提供充足的发现空间 | +| **成长感**(Sense of Growth) | ★★★★★ | 元经验 + 历史对比提供强烈的跨轮成长反馈 | +| **紧张感**(Tension) | ★★☆☆☆ | 10秒倒计时和死亡机制有紧张感,但密度太低 | +| **叙事感**(Narrative) | ★★★☆☆ | 事件链和NPC提供叙事,但MVP内容量不足 | +| **掌控感**(Agency) | ★★★★☆ | 选择事件 + 硬门槛让玩家感到"我知道自己在做什么" | +| **社交感**(Social) | ★☆☆☆☆ | 目前完全没有社交元素 | + +**体验层最大缺口:紧张感** + +当前设计中,玩家每世只有 5~15 个选择事件,且 10 秒倒计时对于"加速模式"下的玩家来说几乎无感(他们可能在喝水/看手机)。 + +**修复建议**: +- 引入"突发事件":加速时概率触发紧急事件,强制暂停 +- 引入"倒计时危机":某些选择事件如果选错,不是立即死亡,而是触发"危机链"(如"你中了毒,接下来30天每天体质-1,必须找到解药") + +--- + +## 二、心流通道分析 + +### 2.1 心流匹配图 + +``` +挑战 +↑ +│ ★ 终局对抗三相 +│ ★ +│ ★ +│ ★ 解锁隐藏第四相 +│ ★ +│ ★ 对抗外敌事件 +│ ★ 选择事件(高风险) +│★ 日常事件(低风险) +└──────────────────────────→ 技能(玩家经验) + 新手 熟练 专家 +``` + +**分析**: +- 早期(新手):日常事件太简单,选择事件难度跳跃太大。存在"无聊期"和"焦虑期"的交替。 +- 中期(熟练):外敌事件提供适度挑战,但日常事件仍然太简单。 +- 后期(专家):终局挑战足够,但到达终局的过程太长。 + +**修复建议**: +- 引入"自适应难度":根据玩家历史最佳表现调整事件难度 +- 引入"挑战模式":玩家可以主动选择更高风险的事件链,换取更高回报 + +### 2.2 死亡机制的心流影响 + +当前死亡是"自然衰减"(年龄越大概率越高)+ "外敌阈值"。 + +**问题**:死亡太"平淡"。玩家活到80岁,看到"寿终正寝",没有任何情绪冲击。 + +**修复建议(借鉴《暗黑地牢》的Stress系统)**: +- 引入"危机感"数值:随着年龄/外敌增长,玩家每天的危机感上升 +- 危机感过高时:事件池偏向负面事件,选择事件选项减少 +- 危机感可以通过特定事件降低(如"归隐田园") + +--- + +## 三、斯金纳箱 / 可变奖励分析 + +### 3.1 奖励类型分布 + +| 奖励类型 | 频率 | 可预测性 | 成瘾性 | +|---|---|---|---| +| 每天经验增长 | 极高(每天) | 完全可预测 | 低 | +| 职业升级 | 中等(几天一次) | 可预测 | 中 | +| 文本事件 | 高(每天多次) | 随机 | 中 | +| 选择事件 | 中等(每年几次) | 半随机 | 高 | +| 词条解锁 | 低(每几世一次) | 随机 | **极高** | +| 新职业解锁 | 很低(几十世一次) | 条件驱动 | 高 | +| 外敌击退 | 极低 | 长期目标 | 中 | + +### 3.2 成瘾机制审计 + +**正面成瘾设计**: +- ✅ 词条系统 = 抽卡机制(可变奖励) +- ✅ 历史对比 = 社交比较("我比上一世强了") +- ✅ 元经验 = 沉没成本("我已经练了20世了,不能放弃") + +**负面成瘾风险**: +- ⚠️ 词条池太小 → 很快变成"重复劳动"而非"期待惊喜" +- ⚠️ 日常事件太频繁 → 玩家可能"挂机刷",失去主动参与感 +- ⚠️ 没有"保底机制" → 玩家可能几十世都抽不到想要的词条,产生挫败感 + +**修复建议**: +- 引入"词条保底":每10世必出一个未解锁词条 +- 引入"词条许愿":可以指定1个词条,抽到它的概率翻倍(但其他词条概率降低) + +--- + +## 四、反馈密度分析 + +### 4.1 每世内的反馈密度 + +假设一局人生60年: + +| 年龄段 | 天数 | 文本事件 | 选择事件 | 反馈/天 | +|---|---|---|---|---| +| 幼年 | 7年 | ~150个 | 3个 | 0.06 | +| 少年 | 9年 | ~120个 | 4个 | 0.04 | +| 青年 | 10年 | ~70个 | 3个 | 0.02 | +| 壮年 | 15年 | ~60个 | 3个 | 0.01 | +| 中年 | 20年 | ~60个 | 2个 | 0.008 | +| 老年 | 10年 | ~70个 | 3个 | 0.02 | + +**问题**:壮年/中年期的反馈密度太低(每天0.01次事件)。加速模式下,玩家每分钟可能只看到1-2个事件。 + +**修复建议**: +- 引入"里程碑事件":每5年强制触发一个有纪念意义的事件(如"你第一次独立完成了...") +- 引入"能力突破事件":每升10级触发一个特殊事件,提供强烈的正反馈 + +### 4.2 跨世反馈密度 + +| 轮回 | 预期反馈 | 评价 | +|---|---|---| +| 1-3 | 大量新内容解锁 | 极高密度,容易上头 | +| 4-10 | 新词条 + 元经验加速 | 高密度,持续吸引 | +| 11-20 | 元经验 + 偶尔新词条 | 中密度,需要事件多样性支撑 | +| 21-50 | 元经验 + 外敌推进 | 低密度,风险期 | +| 51+ | 终局目标可见 | 密度回升,但需要前期不流失 | + +**关键风险期:第21-50世** + +这段时间玩家已经解锁了大部分内容,日常体验变成"刷经验 → 死亡 → 刷经验"。如果没有任何新的刺激,玩家会在这个阶段流失。 + +**修复建议**: +- 引入"世代事件":每10世触发一个独特的世界观事件(如"第30世:天魔第一次降临") +- 引入"传承系统":玩家可以给下一世留下"遗言"或"遗志",下一世看到时有特殊加成 + +--- + +## 五、长期留存模型 + +### 5.1 留存曲线预测 + +``` +留存率 +100% ┤★ + 80% ┤ ★ + 60% ┤ ★★ + 40% ┤ ★★★ + 20% ┤ ★★★★ + 10% ┤ ★★★★★★ + 5% ┤ ★★★★★★★★ + └──────────────────────── + D1 D3 D7 D14 D30 D60 D90 +``` + +**预测**: +- D1 留存:~80%(首日体验丰富,解锁大量内容) +- D7 留存:~40%(第3-4世后内容重复感出现) +- D30 留存:~15%(如果没有足够的世代事件,玩家流失) +- D90 留存:~5%(只有核心玩家坚持到终局) + +### 5.2 提升留存的设计建议 + +**短期(D1-D7)**: +- 前3世强制体验3条不同路线 +- 每日登录奖励(解锁特殊词条) +- "上一世的你留下了..."(每次登录时的叙事钩子) + +**中期(D7-D30)**: +- 每周挑战("本周只能走武道路线",完成奖励词条) +- 玩家排行榜("你的第10世在历史排名中第X") +- 事件编辑器(让玩家自己写事件,被点赞多的加入官方池) + +**长期(D30+)**: +- 终局赛季制(每赛季重置外敌进度,但保留词条池) +- 多人异步(看到其他玩家的"墓碑"和遗言) +- MOD支持(社区创作内容) + +--- + +## 六、关键设计矛盾与修复方案 + +### 矛盾1:"无限试错" vs "路线疲劳" + +**问题**:玩家发现最优路线后,会反复走同一条路。 +**修复**: +- 同职业连续3世,元经验收益 ×0.7 +- 新职业首世,元经验收益 ×2.0 +- 引入"跨界技能":两个职业都达标后解锁组合技能 + +### 矛盾2:"每天推进" vs "信息过载" + +**问题**:按天推进意味着每年有365个tick,事件密度太高会 overwhelm 玩家,太低会显得空洞。 +**修复**: +- 引入"事件批次":每天不触发事件,而是累积"事件概率",达到阈值时一次性触发 +- 加速模式下,事件自动聚合("这一周发生了3件事...") + +### 矛盾3:"随机词条" vs "策略规划" + +**问题**:如果词条完全随机,玩家无法做长期规划;如果词条可预测,就失去了发现感。 +**修复**: +- 引入"词条倾向":根据上一世的选择,某些词条概率提升(如上一世当混混,"江湖中人"词条概率+50%) +- 引入"词条预览":重生前可以看到"下一世有30%概率抽到剑骨" + +### 矛盾4:"硬门槛" vs "戏剧性" + +**问题**:纯硬门槛缺少"差一点就成功"的戏剧性。 +**修复**: +- 硬门槛用于"不可能事件"(如悟性<3不可能修仙) +- 门槛内引入"浮动奖励":属性刚好够门槛时,奖励翻倍;远超门槛时,奖励正常 + +--- + +## 七、总结与优先级 + +### 必须修复(MVP阶段) + +1. **词条池扩大**:MVP至少40-60个词条,或引入词条组合 +2. **事件密度优化**:壮年/中年期每天至少有1个可见事件 +3. **死亡冲击力**:引入"危机感"系统,让死亡前有预兆 +4. **首因效应**:前3世强制引导玩家走不同路线 + +### 应该修复(EA阶段) + +5. **世代事件**:每10世一个独特的世界观事件 +6. **词条保底**:每10世必出1个未解锁词条 +7. **NPC深度**:至少1个贯穿多世的深度NPC +8. **里程碑事件**:每5年/每10级一个强反馈事件 + +### 可以延后(正式版) + +9. 社交/异步交互 +10. MOD系统 +11. AI生成事件 +12. 赛季制 + +--- + +## 八、最终评分 + +| 维度 | 评分 | 说明 | +|---|---|---| +| 核心循环完整性 | 8/10 | 循环清晰,但终局目标太遥远 | +| 反馈密度 | 6/10 | 中后期密度不足,容易疲劳 | +| 随机性平衡 | 7/10 | 硬门槛+随机词条是好的,但词条池太小 | +| 长期留存潜力 | 6/10 | 50-100世的目标需要更多中间里程碑支撑 | +| 叙事深度 | 5/10 | MVP内容量不足以支撑叙事 | +| 成瘾性设计 | 7/10 | 抽卡式词条+历史对比是好设计,但需要保底 | +| 心流匹配 | 6/10 | 早期和晚期匹配好,中期存在空窗期 | +| **综合评分** | **6.4/10** | 基础框架扎实,但需要大量内容填充和中期体验优化 | + +**结论**:设计方向正确,核心循环有潜力。MVP阶段最大的风险是"内容量不足导致早期流失"和"中期空窗期导致玩家疲劳"。建议优先扩大词条池、增加事件密度、引入世代事件。 diff --git a/assets/audio/bgm_battle.mp3 b/assets/audio/bgm_battle.mp3 new file mode 100644 index 0000000..cbb1082 Binary files /dev/null and b/assets/audio/bgm_battle.mp3 differ diff --git a/assets/audio/bgm_main.mp3 b/assets/audio/bgm_main.mp3 new file mode 100644 index 0000000..652c0ae Binary files /dev/null and b/assets/audio/bgm_main.mp3 differ diff --git a/assets/images/divine_brush_icon_001.jpg b/assets/images/divine_brush_icon_001.jpg new file mode 100644 index 0000000..9a19681 Binary files /dev/null and b/assets/images/divine_brush_icon_001.jpg differ diff --git a/assets/images/invasion_military_001.jpg b/assets/images/invasion_military_001.jpg new file mode 100644 index 0000000..19da1ca Binary files /dev/null and b/assets/images/invasion_military_001.jpg differ diff --git a/assets/images/invasion_political_001.jpg b/assets/images/invasion_political_001.jpg new file mode 100644 index 0000000..5398947 Binary files /dev/null and b/assets/images/invasion_political_001.jpg differ diff --git a/assets/images/invasion_spiritual_001.jpg b/assets/images/invasion_spiritual_001.jpg new file mode 100644 index 0000000..c8387b2 Binary files /dev/null and b/assets/images/invasion_spiritual_001.jpg differ diff --git a/assets/images/jade_seal_icon_001.jpg b/assets/images/jade_seal_icon_001.jpg new file mode 100644 index 0000000..6a4fc99 Binary files /dev/null and b/assets/images/jade_seal_icon_001.jpg differ diff --git a/assets/images/rebirth_scene_001.jpg b/assets/images/rebirth_scene_001.jpg new file mode 100644 index 0000000..cf97685 Binary files /dev/null and b/assets/images/rebirth_scene_001.jpg differ diff --git a/assets/images/sword_icon_001.jpg b/assets/images/sword_icon_001.jpg new file mode 100644 index 0000000..dbbfce1 Binary files /dev/null and b/assets/images/sword_icon_001.jpg differ diff --git a/assets/images/talisman_icon_001.jpg b/assets/images/talisman_icon_001.jpg new file mode 100644 index 0000000..bbb72ed Binary files /dev/null and b/assets/images/talisman_icon_001.jpg differ diff --git a/assets/images/tiger_tally_icon_001.jpg b/assets/images/tiger_tally_icon_001.jpg new file mode 100644 index 0000000..0736e92 Binary files /dev/null and b/assets/images/tiger_tally_icon_001.jpg differ diff --git a/assets/images/title_screen_001.jpg b/assets/images/title_screen_001.jpg new file mode 100644 index 0000000..d37d7a6 Binary files /dev/null and b/assets/images/title_screen_001.jpg differ diff --git a/assets/images/treasure_bowl_icon_001.jpg b/assets/images/treasure_bowl_icon_001.jpg new file mode 100644 index 0000000..7b25a85 Binary files /dev/null and b/assets/images/treasure_bowl_icon_001.jpg differ diff --git a/docs/superpowers/plans/2026-05-13-config-driven-rebuild.md b/docs/superpowers/plans/2026-05-13-config-driven-rebuild.md new file mode 100644 index 0000000..1dc09ec --- /dev/null +++ b/docs/superpowers/plans/2026-05-13-config-driven-rebuild.md @@ -0,0 +1,2760 @@ +# 轮回录配置驱动重构 - 实现计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将《轮回录》全部重写为配置驱动框架,实现网状职业系统、银两经济、3Tab商铺、年龄触发入侵事件。 + +**Architecture:** 纯配置驱动引擎(`engine/`)+ JSON内容(`config/`)+ UI渲染器(`ui/`)。引擎零硬编码,通过 `conditionEvaluator` 和 `effectApplier` 统一处理所有条件判断和状态修改。 + +**Tech Stack:** 纯前端浏览器项目,ES Modules,无构建工具,测试用 Node.js + 简单断言。 + +--- + +## 文件结构 + +``` +src/ + engine/ + state.js # 状态管理、序列化、跨轮继承 + conditionEvaluator.js # 统一条件解析器 + effectApplier.js # 统一效果执行器 + careerEngine.js # 职业解锁、经验计算、收入结算 + moneySystem.js # 银两收入/支出/余额 + eventEngine.js # 事件触发、选择处理 + shopEngine.js # 商铺购买、生效、重置 + invasionEngine.js # 入侵事件触发、破局判定 + deathEngine.js # 死亡检查、重生结算 + tickLoop.js # 游戏主循环 + config/ + careers.json # 职业定义 + events.json # 事件定义 + shop.json # 商铺定义 + identities.json # 开局身份 + talents.json # 词条定义 + ui/ + renderer.js # 主渲染器 + main.js # 入口 +``` + +--- + +## Task 1: 条件解析器 (conditionEvaluator.js) + +**Files:** +- Create: `src/engine/conditionEvaluator.js` +- Test: `test/conditionEvaluator.test.js` + +**说明:** 所有系统的条件判断都走这里。输入一个条件JSON对象 + state,返回 boolean。 + +- [ ] **Step 1: 写测试文件** + +```javascript +// test/conditionEvaluator.test.js +import { evaluateCondition } from '../src/engine/conditionEvaluator.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +const baseState = { + age: 15, + reincarnation: 2, + money: 300, + stats: { body: 30, wisdom: 20, charm: 10, destiny: 5, business: 8, intelligence: 15 }, + careers: { student: { level: 120 } }, + identity: 'orphan', + worldFlags: { met_taoist: true }, + shopItems: { taoist_license: 1 }, + artifacts: { immortal_sword: 3 } +}; + +function testAgeCondition() { + assert(evaluateCondition({ type: 'age', op: '>=', value: 14 }, baseState) === true, 'age >= 14'); + assert(evaluateCondition({ type: 'age', op: '>=', value: 20 }, baseState) === false, 'age >= 20'); + console.log('testAgeCondition PASS'); +} + +function testStatCondition() { + assert(evaluateCondition({ type: 'stat', stat: 'body', op: '>=', value: 25 }, baseState) === true, 'body >= 25'); + assert(evaluateCondition({ type: 'stat', stat: 'body', op: '>=', value: 35 }, baseState) === false, 'body >= 35'); + console.log('testStatCondition PASS'); +} + +function testCareerLevelCondition() { + assert(evaluateCondition({ type: 'careerLevel', careerId: 'student', op: '>=', value: 100 }, baseState) === true, 'student >= 100'); + assert(evaluateCondition({ type: 'careerLevel', careerId: 'student', op: '>=', value: 200 }, baseState) === false, 'student >= 200'); + console.log('testCareerLevelCondition PASS'); +} + +function testMoneyCondition() { + assert(evaluateCondition({ type: 'money', op: '>=', value: 200 }, baseState) === true, 'money >= 200'); + assert(evaluateCondition({ type: 'money', op: '>=', value: 500 }, baseState) === false, 'money >= 500'); + console.log('testMoneyCondition PASS'); +} + +function testIdentityCondition() { + assert(evaluateCondition({ type: 'identity', value: 'orphan' }, baseState) === true, 'identity orphan'); + assert(evaluateCondition({ type: 'identity', value: 'noble' }, baseState) === false, 'identity noble'); + console.log('testIdentityCondition PASS'); +} + +function testWorldFlagCondition() { + assert(evaluateCondition({ type: 'worldFlag', flag: 'met_taoist', value: true }, baseState) === true, 'flag met_taoist'); + assert(evaluateCondition({ type: 'worldFlag', flag: 'met_taoist', value: false }, baseState) === false, 'flag not met_taoist'); + console.log('testWorldFlagCondition PASS'); +} + +function testHasItemCondition() { + assert(evaluateCondition({ type: 'hasItem', itemId: 'taoist_license' }, baseState) === true, 'has taoist_license'); + assert(evaluateCondition({ type: 'hasItem', itemId: 'missing' }, baseState) === false, 'not has missing'); + console.log('testHasItemCondition PASS'); +} + +function testHasArtifactCondition() { + assert(evaluateCondition({ type: 'hasArtifact', artifactId: 'immortal_sword', op: '>=', value: 3 }, baseState) === true, 'sword >= 3'); + assert(evaluateCondition({ type: 'hasArtifact', artifactId: 'immortal_sword', op: '>=', value: 5 }, baseState) === false, 'sword >= 5'); + console.log('testHasArtifactCondition PASS'); +} + +function testReincarnationCondition() { + assert(evaluateCondition({ type: 'reincarnation', op: '>=', value: 2 }, baseState) === true, 'reincarnation >= 2'); + assert(evaluateCondition({ type: 'reincarnation', op: '>=', value: 5 }, baseState) === false, 'reincarnation >= 5'); + console.log('testReincarnationCondition PASS'); +} + +function testAndCondition() { + const cond = { + type: 'and', + conditions: [ + { type: 'age', op: '>=', value: 14 }, + { type: 'stat', stat: 'body', op: '>=', value: 25 } + ] + }; + assert(evaluateCondition(cond, baseState) === true, 'and true'); + console.log('testAndCondition PASS'); +} + +function testOrCondition() { + const cond = { + type: 'or', + conditions: [ + { type: 'age', op: '>=', value: 20 }, + { type: 'stat', stat: 'body', op: '>=', value: 25 } + ] + }; + assert(evaluateCondition(cond, baseState) === true, 'or true (second)'); + console.log('testOrCondition PASS'); +} + +function testNotCondition() { + const cond = { type: 'not', condition: { type: 'age', op: '>=', value: 20 } }; + assert(evaluateCondition(cond, baseState) === true, 'not age>=20'); + console.log('testNotCondition PASS'); +} + +testAgeCondition(); +testStatCondition(); +testCareerLevelCondition(); +testMoneyCondition(); +testIdentityCondition(); +testWorldFlagCondition(); +testHasItemCondition(); +testHasArtifactCondition(); +testReincarnationCondition(); +testAndCondition(); +testOrCondition(); +testNotCondition(); +console.log('All conditionEvaluator tests PASS'); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node test/conditionEvaluator.test.js` +Expected: 报错,模块未找到 + +- [ ] **Step 3: 实现 conditionEvaluator.js** + +```javascript +// src/engine/conditionEvaluator.js + +const OPS = { + '==': (a, b) => a == b, + '===': (a, b) => a === b, + '!=': (a, b) => a != b, + '!==': (a, b) => a !== b, + '>': (a, b) => a > b, + '>=': (a, b) => a >= b, + '<': (a, b) => a < b, + '<=': (a, b) => a <= b, +}; + +export function evaluateCondition(condition, state) { + if (!condition || typeof condition !== 'object') return true; + + switch (condition.type) { + case 'age': + return OPS[condition.op](state.age, condition.value); + + case 'stat': + return OPS[condition.op](state.stats[condition.stat] || 0, condition.value); + + case 'careerLevel': { + const career = state.careers[condition.careerId]; + return OPS[condition.op](career ? career.level : 0, condition.value); + } + + case 'money': + return OPS[condition.op](state.money, condition.value); + + case 'identity': + return state.identity === condition.value; + + case 'worldFlag': + return !!state.worldFlags[condition.flag] === condition.value; + + case 'hasItem': + return !!state.shopItems[condition.itemId]; + + case 'hasArtifact': { + const level = state.artifacts[condition.artifactId] || 0; + return OPS[condition.op](level, condition.value); + } + + case 'reincarnation': + return OPS[condition.op](state.reincarnation, condition.value); + + case 'and': + return condition.conditions.every(c => evaluateCondition(c, state)); + + case 'or': + return condition.conditions.some(c => evaluateCondition(c, state)); + + case 'not': + return !evaluateCondition(condition.condition, state); + + default: + return true; + } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node test/conditionEvaluator.test.js` +Expected: `All conditionEvaluator tests PASS` + +- [ ] **Step 5: Commit** + +```bash +git add test/conditionEvaluator.test.js src/engine/conditionEvaluator.js +git commit -m "feat: add condition evaluator with full operator support" +``` + +--- + +## Task 2: 效果执行器 (effectApplier.js) + +**Files:** +- Create: `src/engine/effectApplier.js` +- Test: `test/effectApplier.test.js` + +**说明:** 所有系统的状态修改都走这里。输入一个效果JSON对象 + state,直接修改 state。 + +- [ ] **Step 1: 写测试文件** + +```javascript +// test/effectApplier.test.js +import { applyEffect } from '../src/engine/effectApplier.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testAddMoney() { + const s = { money: 100 }; + applyEffect({ type: 'addMoney', value: 50 }, s); + assert(s.money === 150, 'addMoney'); + console.log('testAddMoney PASS'); +} + +function testAddStat() { + const s = { stats: { body: 10, wisdom: 5 } }; + applyEffect({ type: 'addStat', stat: 'body', value: 3 }, s); + assert(s.stats.body === 13, 'addStat body'); + console.log('testAddStat PASS'); +} + +function testSetFlag() { + const s = { worldFlags: {} }; + applyEffect({ type: 'setFlag', flag: 'test_flag', value: true }, s); + assert(s.worldFlags.test_flag === true, 'setFlag'); + console.log('testSetFlag PASS'); +} + +function testUnlockCareer() { + const s = { careers: {} }; + applyEffect({ type: 'unlockCareer', careerId: 'soldier' }, s); + assert(s.careers.soldier.level === 0, 'unlockCareer level'); + assert(s.careers.soldier.exp === 0, 'unlockCareer exp'); + console.log('testUnlockCareer PASS'); +} + +function testAddExp() { + const s = { careers: { student: { level: 0, exp: 0 } } }; + applyEffect({ type: 'addExp', careerId: 'student', value: 100 }, s); + assert(s.careers.student.exp === 100, 'addExp'); + console.log('testAddExp PASS'); +} + +function testAddItem() { + const s = { shopItems: {} }; + applyEffect({ type: 'addItem', itemId: 'sword' }, s); + assert(s.shopItems.sword === 1, 'addItem'); + console.log('testAddItem PASS'); +} + +function testAddBuff() { + const s = { day: 10, activeBuffs: {} }; + applyEffect({ type: 'addBuff', buffId: 'power_up', duration: 30 }, s); + assert(s.activeBuffs.power_up.expiresDay === 40, 'addBuff expiresDay'); + console.log('testAddBuff PASS'); +} + +function testUpgradeArtifact() { + const s = { artifacts: { sword: 2 } }; + applyEffect({ type: 'upgradeArtifact', artifactId: 'sword' }, s); + assert(s.artifacts.sword === 3, 'upgradeArtifact'); + console.log('testUpgradeArtifact PASS'); +} + +function testAddLog() { + const s = { logs: [], day: 5, year: 1, age: 10 }; + applyEffect({ type: 'addLog', text: 'test', logType: 'normal' }, s); + assert(s.logs.length === 1, 'addLog length'); + assert(s.logs[0].text === 'test', 'addLog text'); + console.log('testAddLog PASS'); +} + +function testDie() { + const s = { alive: true }; + applyEffect({ type: 'die', reason: 'test death' }, s); + assert(s.alive === false, 'die alive'); + assert(s.deathReason === 'test death', 'die reason'); + console.log('testDie PASS'); +} + +testAddMoney(); +testAddStat(); +testSetFlag(); +testUnlockCareer(); +testAddExp(); +testAddItem(); +testAddBuff(); +testUpgradeArtifact(); +testAddLog(); +testDie(); +console.log('All effectApplier tests PASS'); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node test/effectApplier.test.js` +Expected: 报错,模块未找到 + +- [ ] **Step 3: 实现 effectApplier.js** + +```javascript +// src/engine/effectApplier.js + +export function applyEffect(effect, state) { + if (!effect || typeof effect !== 'object') return; + + switch (effect.type) { + case 'addMoney': + state.money = (state.money || 0) + effect.value; + break; + + case 'addStat': + if (!state.stats) state.stats = {}; + state.stats[effect.stat] = (state.stats[effect.stat] || 0) + effect.value; + break; + + case 'setStat': + if (!state.stats) state.stats = {}; + state.stats[effect.stat] = effect.value; + break; + + case 'setFlag': + if (!state.worldFlags) state.worldFlags = {}; + state.worldFlags[effect.flag] = effect.value; + break; + + case 'unlockCareer': + if (!state.careers) state.careers = {}; + if (!state.careers[effect.careerId]) { + state.careers[effect.careerId] = { level: 0, exp: 0 }; + } + break; + + case 'addExp': { + const career = state.careers[effect.careerId]; + if (career) { + career.exp = (career.exp || 0) + effect.value; + } + break; + } + + case 'addItem': + if (!state.shopItems) state.shopItems = {}; + state.shopItems[effect.itemId] = (state.shopItems[effect.itemId] || 0) + 1; + break; + + case 'addBuff': + if (!state.activeBuffs) state.activeBuffs = {}; + state.activeBuffs[effect.buffId] = { + expiresDay: effect.duration === -1 ? -1 : (state.day || 0) + effect.duration + }; + break; + + case 'upgradeArtifact': + if (!state.artifacts) state.artifacts = {}; + state.artifacts[effect.artifactId] = (state.artifacts[effect.artifactId] || 0) + 1; + break; + + case 'addLog': + if (!state.logs) state.logs = []; + state.logs.unshift({ + day: state.day || 0, + year: state.year || 0, + age: state.age || 0, + text: effect.text, + type: effect.logType || 'normal', + timestamp: Date.now(), + }); + if (state.logs.length > 500) state.logs.pop(); + break; + + case 'die': + state.alive = false; + state.deathReason = effect.reason; + break; + + default: + break; + } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node test/effectApplier.test.js` +Expected: `All effectApplier tests PASS` + +- [ ] **Step 5: Commit** + +```bash +git add test/effectApplier.test.js src/engine/effectApplier.js +git commit -m "feat: add effect applier with all core effect types" +``` + +--- + +## Task 3: 状态管理 (state.js) + +**Files:** +- Create: `src/engine/state.js` +- Test: `test/state.test.js` + +**说明:** 定义完整状态结构、初始化函数、重置函数、存档/读档函数。 + +- [ ] **Step 1: 写测试文件** + +```javascript +// test/state.test.js +import { createInitialState, resetForRebirth, serializeState, deserializeState } from '../src/engine/state.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testCreateInitialState() { + const s = createInitialState(); + assert(s.age === 0, 'age'); + assert(s.alive === true, 'alive'); + assert(s.stats.body === 0, 'stats.body'); + assert(s.money === 0, 'money'); + assert(Object.keys(s.careers).length === 0, 'careers empty'); + assert(s.artifacts !== undefined, 'artifacts exists'); + console.log('testCreateInitialState PASS'); +} + +function testResetForRebirth() { + const s = createInitialState(); + s.age = 50; + s.money = 1000; + s.careers.student = { level: 100, exp: 0 }; + s.artifacts.sword = 3; + s.metaExp.student = 500; + s.reincarnation = 0; + + resetForRebirth(s); + + assert(s.reincarnation === 1, 'reincarnation incremented'); + assert(s.age === 0, 'age reset'); + assert(s.money === 0, 'money reset'); + assert(Object.keys(s.careers).length === 0, 'careers reset'); + assert(s.artifacts.sword === 3, 'artifacts preserved'); + assert(s.metaExp.student === 500, 'metaExp preserved'); + console.log('testResetForRebirth PASS'); +} + +function testSerializeDeserialize() { + const s = createInitialState(); + s.age = 30; + s.stats.body = 25; + s.careers.student = { level: 50, exp: 100 }; + s.artifacts.sword = 2; + s.unlockedShopPool = new Set(['item1']); + + const data = serializeState(s); + const s2 = createInitialState(); + deserializeState(data, s2); + + assert(s2.age === 30, 'deserialize age'); + assert(s2.stats.body === 25, 'deserialize stats'); + assert(s2.careers.student.level === 50, 'deserialize career'); + assert(s2.artifacts.sword === 2, 'deserialize artifact'); + assert(s2.unlockedShopPool.has('item1'), 'deserialize unlockedShopPool'); + console.log('testSerializeDeserialize PASS'); +} + +testCreateInitialState(); +testResetForRebirth(); +testSerializeDeserialize(); +console.log('All state tests PASS'); +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `node test/state.test.js` +Expected: 报错 + +- [ ] **Step 3: 实现 state.js** + +```javascript +// src/engine/state.js + +export function createInitialState() { + return { + day: 0, + year: 0, + age: 0, + reincarnation: 0, + + alive: true, + identity: null, + stats: { + body: 0, + wisdom: 0, + charm: 0, + destiny: 0, + business: 0, + intelligence: 0 + }, + talents: [], + + careers: {}, + money: 0, + + shopItems: {}, + activeBuffs: {}, + artifacts: {}, + + worldFlags: {}, + npcs: {}, + + invasionsTriggered: { + military_40: false, + spiritual_45: false, + political_50: false, + final_60: false + }, + breakthroughRoute: null, + + speed: 0, + paused: false, + logs: [], + triggeredEvents: new Set(), + + metaExp: {}, + memories: [], + unlockedEntries: new Set(), + history: [], + unlockedShopPool: new Set(), + }; +} + +export function resetForRebirth(state) { + // 保存本轮记录到历史 + const record = { + reincarnation: state.reincarnation, + age: state.age, + day: state.day, + careers: Object.fromEntries( + Object.entries(state.careers).map(([k, v]) => [k, { level: v.level }]) + ), + identity: state.identity, + stats: { ...state.stats }, + money: state.money, + }; + state.history.push(record); + if (state.history.length > 50) state.history.shift(); + + // 跨轮保留 + const preserved = { + reincarnation: state.reincarnation + 1, + metaExp: state.metaExp, + memories: state.memories, + unlockedEntries: state.unlockedEntries, + history: state.history, + artifacts: state.artifacts, + unlockedShopPool: state.unlockedShopPool, + }; + + // 重置本轮状态 + Object.assign(state, createInitialState(), preserved); +} + +export function serializeState(state) { + return { + day: state.day, + year: state.year, + age: state.age, + reincarnation: state.reincarnation, + alive: state.alive, + identity: state.identity, + stats: state.stats, + talents: state.talents, + careers: state.careers, + money: state.money, + shopItems: state.shopItems, + activeBuffs: state.activeBuffs, + artifacts: state.artifacts, + worldFlags: state.worldFlags, + npcs: state.npcs, + invasionsTriggered: state.invasionsTriggered, + breakthroughRoute: state.breakthroughRoute, + speed: state.speed, + paused: state.paused, + logs: state.logs.slice(0, 100), + triggeredEvents: Array.from(state.triggeredEvents), + metaExp: state.metaExp, + memories: state.memories, + unlockedEntries: Array.from(state.unlockedEntries), + history: state.history, + unlockedShopPool: Array.from(state.unlockedShopPool), + }; +} + +export function deserializeState(data, target) { + Object.assign(target, { + day: data.day || 0, + year: data.year || 0, + age: data.age || 0, + reincarnation: data.reincarnation || 0, + alive: data.alive !== false, + identity: data.identity || null, + stats: data.stats || { body: 0, wisdom: 0, charm: 0, destiny: 0, business: 0, intelligence: 0 }, + talents: data.talents || [], + careers: data.careers || {}, + money: data.money || 0, + shopItems: data.shopItems || {}, + activeBuffs: data.activeBuffs || {}, + artifacts: data.artifacts || {}, + worldFlags: data.worldFlags || {}, + npcs: data.npcs || {}, + invasionsTriggered: data.invasionsTriggered || { military_40: false, spiritual_45: false, political_50: false, final_60: false }, + breakthroughRoute: data.breakthroughRoute || null, + speed: data.speed || 0, + paused: data.paused || false, + logs: data.logs || [], + triggeredEvents: new Set(data.triggeredEvents || []), + metaExp: data.metaExp || {}, + memories: data.memories || [], + unlockedEntries: new Set(data.unlockedEntries || []), + history: data.history || [], + unlockedShopPool: new Set(data.unlockedShopPool || []), + }); +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node test/state.test.js` +Expected: `All state tests PASS` + +- [ ] **Step 5: Commit** + +```bash +git add test/state.test.js src/engine/state.js +git commit -m "feat: add state management with full serialization and rebirth reset" +``` + +--- + +## Task 4: 职业引擎 (careerEngine.js) + +**Files:** +- Create: `src/engine/careerEngine.js` +- Create: `src/config/careers.json` +- Test: `test/careerEngine.test.js` + +**说明:** 加载职业配置,提供职业解锁检查、每日经验结算、每日收入结算、经验升级检查。 + +- [ ] **Step 1: 写职业配置 careers.json** + +```json +{ + "careers": [ + { + "id": "student", + "name": "学童", + "type": "scholar", + "unlockConditions": [ + { "type": "age", "op": ">=", "value": 6 } + ], + "dailyIncome": 1, + "expCurve": { "base": 10, "factor": 1.15 } + }, + { + "id": "book_boy", + "name": "书童", + "type": "scholar", + "unlockConditions": [ + { "type": "careerLevel", "careerId": "student", "op": ">=", "value": 100 } + ], + "dailyIncome": 2, + "expCurve": { "base": 50, "factor": 1.2 } + }, + { + "id": "scholar", + "name": "书生", + "type": "scholar", + "unlockConditions": [ + { "type": "careerLevel", "careerId": "student", "op": ">=", "value": 250 }, + { "type": "age", "op": ">=", "value": 14 } + ], + "dailyIncome": -3, + "expCurve": { "base": 100, "factor": 1.25 } + }, + { + "id": "soldier", + "name": "士兵", + "type": "military", + "unlockConditions": [ + { "type": "age", "op": ">=", "value": 14 }, + { "type": "stat", "stat": "body", "op": ">=", "value": 10 } + ], + "dailyIncome": 2, + "expCurve": { "base": 20, "factor": 1.18 } + }, + { + "id": "taoist_priest", + "name": "道士", + "type": "immortal", + "unlockConditions": [ + { "type": "age", "op": ">=", "value": 10 }, + { "type": "hasItem", "itemId": "taoist_license" } + ], + "dailyIncome": 1, + "expCurve": { "base": 30, "factor": 1.22 } + } + ] +} +``` + +- [ ] **Step 2: 写测试文件** + +```javascript +// test/careerEngine.test.js +import { loadCareerConfig, checkUnlocks, calcDailyIncome, calcDailyExp, checkLevelUp, STAT_MAP } from '../src/engine/careerEngine.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testLoadConfig() { + const config = loadCareerConfig('../src/config/careers.json'); + assert(config.careers.length > 0, 'config loaded'); + assert(config.careers.find(c => c.id === 'student'), 'student exists'); + console.log('testLoadConfig PASS'); +} + +function testCheckUnlocks() { + const config = { + careers: [ + { + id: 'student', + unlockConditions: [{ type: 'age', op: '>=', value: 6 }] + }, + { + id: 'book_boy', + unlockConditions: [{ type: 'careerLevel', careerId: 'student', op: '>=', value: 100 }] + } + ] + }; + + const state = { age: 10, careers: {} }; + checkUnlocks(config, state); + assert(state.careers.student, 'student unlocked'); + assert(!state.careers.book_boy, 'book_boy not unlocked yet'); + + state.careers.student = { level: 100, exp: 0 }; + checkUnlocks(config, state); + assert(state.careers.book_boy, 'book_boy unlocked after student 100'); + console.log('testCheckUnlocks PASS'); +} + +function testCalcDailyIncome() { + const config = { + careers: [ + { id: 'student', dailyIncome: 1 }, + { id: 'soldier', dailyIncome: 2 } + ] + }; + const state = { + careers: { student: { level: 10 }, soldier: { level: 5 } }, + stats: { business: 20 }, + activeBuffs: {} + }; + const income = calcDailyIncome(config, state); + // (1 + 2) * (1 + 20 * 0.01) = 3 * 1.2 = 3.6 + assert(Math.abs(income - 3.6) < 0.01, 'daily income with business bonus'); + console.log('testCalcDailyIncome PASS'); +} + +function testCalcDailyExp() { + const config = { + careers: [ + { id: 'student', type: 'scholar', expCurve: { base: 10, factor: 1.15 } } + ] + }; + const state = { + careers: { student: { level: 0, exp: 0 } }, + stats: { wisdom: 30 }, + metaExp: {}, + activeBuffs: {} + }; + const exp = calcDailyExp(config, state, 'student'); + // base 10 * (1 + 30 * 0.01) = 10 * 1.3 = 13 + assert(Math.abs(exp - 13) < 0.01, 'daily exp with wisdom bonus'); + console.log('testCalcDailyExp PASS'); +} + +function testCheckLevelUp() { + const config = { + careers: [ + { id: 'student', expCurve: { base: 10, factor: 1.15 } } + ] + }; + const state = { + careers: { student: { level: 0, exp: 12 } } + }; + checkLevelUp(config, state); + // need = 10 * 1.15^0 = 10, exp=12 > 10, so level up + assert(state.careers.student.level === 1, 'level up'); + assert(state.careers.student.exp === 2, 'exp remainder'); + console.log('testCheckLevelUp PASS'); +} + +function testStatMap() { + assert(STAT_MAP.scholar === 'wisdom', 'scholar -> wisdom'); + assert(STAT_MAP.military === 'body', 'military -> body'); + assert(STAT_MAP.immortal === 'wisdom', 'immortal -> wisdom'); + console.log('testStatMap PASS'); +} + +testLoadConfig(); +testCheckUnlocks(); +testCalcDailyIncome(); +testCalcDailyExp(); +testCheckLevelUp(); +testStatMap(); +console.log('All careerEngine tests PASS'); +``` + +- [ ] **Step 3: 运行测试确认失败** + +Run: `node test/careerEngine.test.js` +Expected: 报错 + +- [ ] **Step 4: 实现 careerEngine.js** + +```javascript +// src/engine/careerEngine.js + +import { evaluateCondition } from './conditionEvaluator.js'; + +export const STAT_MAP = { + scholar: 'wisdom', + military: 'body', + jianghu: 'charm', + political: 'intelligence', + immortal: 'wisdom', + business: 'business', +}; + +let careerConfig = null; + +export function loadCareerConfig(configPath) { + // 浏览器环境:通过 fetch 或内联导入 + // Node测试环境:通过 fs 读取 + if (typeof window !== 'undefined') { + // 浏览器端由 main.js 加载后调用 setCareerConfig + return null; + } + // Node 测试用:动态导入 JSON + try { + const fs = await import('fs'); + const data = JSON.parse(fs.readFileSync(new URL(configPath, import.meta.url), 'utf8')); + careerConfig = data; + return data; + } catch (e) { + return null; + } +} + +export function setCareerConfig(config) { + careerConfig = config; +} + +export function getCareerConfig() { + return careerConfig; +} + +// 检查所有未解锁职业,条件满足则解锁 +export function checkUnlocks(config, state) { + for (const career of config.careers) { + if (state.careers[career.id]) continue; // 已解锁 + if (evaluateCondition({ type: 'and', conditions: career.unlockConditions }, state)) { + state.careers[career.id] = { level: 0, exp: 0 }; + } + } +} + +// 计算每日总收入(所有已解锁职业的 dailyIncome 求和 * 经商加成) +export function calcDailyIncome(config, state) { + let total = 0; + for (const careerCfg of config.careers) { + const career = state.careers[careerCfg.id]; + if (career) { + total += careerCfg.dailyIncome || 0; + } + } + const business = state.stats.business || 0; + const bonus = 1 + Math.min(business, 100) * 0.01; + return total * bonus; +} + +// 计算某个职业当天的经验收益 +export function calcDailyExp(config, state, careerId) { + const careerCfg = config.careers.find(c => c.id === careerId); + if (!careerCfg) return 0; + + const career = state.careers[careerId]; + if (!career) return 0; + + const base = careerCfg.expCurve.base; + const statKey = STAT_MAP[careerCfg.type] || 'wisdom'; + const stat = state.stats[statKey] || 0; + const statBonus = 1 + Math.min(stat, 100) * 0.01; + + // 元经验加成 + const meta = state.metaExp[careerId] || 0; + const metaBonus = 1 + Math.log(1 + meta / 100); + + return base * statBonus * metaBonus; +} + +// 检查所有职业是否可以升级 +export function checkLevelUp(config, state) { + for (const careerCfg of config.careers) { + const career = state.careers[careerCfg.id]; + if (!career) continue; + + const need = careerCfg.expCurve.base * Math.pow(careerCfg.expCurve.factor, career.level); + while (career.exp >= need) { + career.exp -= need; + career.level++; + } + } +} +``` + +- [ ] **Step 5: 修改测试中的 loadCareerConfig 调用(Node环境问题)** + +由于 Node 测试中 JSON 导入限制,测试中 `testLoadConfig` 需要改为直接使用 `setCareerConfig`: + +```javascript +function testLoadConfig() { + const config = { + careers: [ + { id: 'student', name: '学童', type: 'scholar', unlockConditions: [], dailyIncome: 1, expCurve: { base: 10, factor: 1.15 } } + ] + }; + setCareerConfig(config); + const loaded = getCareerConfig(); + assert(loaded.careers.length > 0, 'config loaded'); + console.log('testLoadConfig PASS'); +} +``` + +- [ ] **Step 6: 运行测试确认通过** + +Run: `node test/careerEngine.test.js` +Expected: `All careerEngine tests PASS` + +- [ ] **Step 7: Commit** + +```bash +git add src/config/careers.json src/engine/careerEngine.js test/careerEngine.test.js +git commit -m "feat: add career engine with unlock, income, exp, level-up" +``` + +--- + +## Task 5: 银两系统 (moneySystem.js) + +**Files:** +- Create: `src/engine/moneySystem.js` +- Test: `test/moneySystem.test.js` + +**说明:** 每日银两结算(收入-支出),购买检查。 + +- [ ] **Step 1: 写测试文件** + +```javascript +// test/moneySystem.test.js +import { applyDailyIncome, canAfford } from '../src/engine/moneySystem.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testApplyDailyIncome() { + const state = { money: 100 }; + applyDailyIncome(state, 15); // 收入15 + assert(state.money === 115, 'positive income'); + + applyDailyIncome(state, -20); // 支出20 + assert(state.money === 95, 'negative income'); + console.log('testApplyDailyIncome PASS'); +} + +function testCanAfford() { + const state = { money: 500 }; + assert(canAfford(state, 300) === true, 'can afford'); + assert(canAfford(state, 600) === false, 'cannot afford'); + console.log('testCanAfford PASS'); +} + +testApplyDailyIncome(); +testCanAfford(); +console.log('All moneySystem tests PASS'); +``` + +- [ ] **Step 2: 实现 moneySystem.js** + +```javascript +// src/engine/moneySystem.js + +export function applyDailyIncome(state, amount) { + state.money = (state.money || 0) + amount; + if (state.money < 0) state.money = 0; +} + +export function canAfford(state, cost) { + return (state.money || 0) >= cost; +} + +export function spendMoney(state, amount) { + if (!canAfford(state, amount)) return false; + state.money -= amount; + return true; +} +``` + +- [ ] **Step 3: 运行测试确认通过** + +Run: `node test/moneySystem.test.js` +Expected: `All moneySystem tests PASS` + +- [ ] **Step 4: Commit** + +```bash +git add src/engine/moneySystem.js test/moneySystem.test.js +git commit -m "feat: add money system with income, afford check, spend" +``` + +--- + +## Task 6: 商铺引擎 (shopEngine.js) + +**Files:** +- Create: `src/engine/shopEngine.js` +- Create: `src/config/shop.json` +- Test: `test/shopEngine.test.js` + +**说明:** 3 Tab商铺系统。购买时检查解锁条件和银两,应用效果。每轮重置物品和Buff,神器跨轮保留。 + +- [ ] **Step 1: 写商铺配置 shop.json** + +```json +{ + "items": [ + { + "id": "taoist_license", + "name": "道碟", + "tab": "item", + "cost": 500, + "unlockConditions": [ + { "type": "age", "op": ">=", "value": 10 } + ], + "effects": [ + { "type": "unlockCareer", "careerId": "taoist_priest" }, + { "type": "setFlag", "flag": "has_taoist_license", "value": true } + ], + "resetOnRebirth": true + }, + { + "id": "power_pill", + "name": "大力丸", + "tab": "item", + "cost": 50, + "unlockConditions": [], + "effects": [ + { "type": "addStat", "stat": "body", "value": 5 } + ], + "resetOnRebirth": true + } + ], + "buffs": [ + { + "id": "clever_brush", + "name": "妙笔生花", + "tab": "buff", + "cost": 200, + "unlockConditions": [], + "effects": [ + { "type": "addStat", "stat": "wisdom", "value": 10 } + ], + "resetOnRebirth": true + } + ], + "artifacts": [ + { + "id": "immortal_sword", + "name": "仙剑", + "tab": "artifact", + "baseCost": 1000, + "costGrowth": 1.5, + "unlockConditions": [ + { "type": "careerLevel", "careerId": "taoist_priest", "op": ">=", "value": 50 } + ], + "effects": [ + { "type": "addStat", "stat": "body", "value": 10 } + ], + "resetOnRebirth": false, + "maxLevel": 10 + } + ] +} +``` + +- [ ] **Step 2: 写测试文件** + +```javascript +// test/shopEngine.test.js +import { setShopConfig, canBuyItem, buyItem, getArtifactUpgradeCost, resetShopItems, applyArtifactEffects } from '../src/engine/shopEngine.js'; +import { evaluateCondition } from '../src/engine/conditionEvaluator.js'; +import { applyEffect } from '../src/engine/effectApplier.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +const testConfig = { + items: [ + { + id: 'power_pill', + name: '大力丸', + tab: 'item', + cost: 50, + unlockConditions: [], + effects: [{ type: 'addStat', stat: 'body', value: 5 }], + resetOnRebirth: true + } + ], + buffs: [ + { + id: 'clever_brush', + name: '妙笔生花', + tab: 'buff', + cost: 200, + unlockConditions: [], + effects: [{ type: 'addStat', stat: 'wisdom', value: 10 }], + resetOnRebirth: true + } + ], + artifacts: [ + { + id: 'sword', + name: '仙剑', + tab: 'artifact', + baseCost: 1000, + costGrowth: 1.5, + unlockConditions: [], + effects: [{ type: 'addStat', stat: 'body', value: 10 }], + resetOnRebirth: false, + maxLevel: 10 + } + ] +}; + +setShopConfig(testConfig); + +function testCanBuyItem() { + const state = { money: 100, stats: { body: 10 }, shopItems: {}, activeBuffs: {}, artifacts: {} }; + assert(canBuyItem(testConfig, state, 'power_pill') === true, 'can buy power_pill'); + + state.money = 30; + assert(canBuyItem(testConfig, state, 'power_pill') === false, 'cannot afford'); + console.log('testCanBuyItem PASS'); +} + +function testBuyItem() { + const state = { money: 100, stats: { body: 10 }, shopItems: {}, activeBuffs: {}, artifacts: {} }; + const result = buyItem(testConfig, state, 'power_pill'); + assert(result === true, 'buy success'); + assert(state.money === 50, 'money deducted'); + assert(state.stats.body === 15, 'body increased'); + assert(state.shopItems.power_pill === 1, 'item recorded'); + console.log('testBuyItem PASS'); +} + +function testGetArtifactUpgradeCost() { + const cost0 = getArtifactUpgradeCost(testConfig, 'sword', 0); + assert(cost0 === 1000, 'upgrade from 0 costs 1000'); + + const cost1 = getArtifactUpgradeCost(testConfig, 'sword', 1); + assert(cost1 === 1500, 'upgrade from 1 costs 1500'); + + const cost2 = getArtifactUpgradeCost(testConfig, 'sword', 2); + assert(cost2 === 2250, 'upgrade from 2 costs 2250'); + console.log('testGetArtifactUpgradeCost PASS'); +} + +function testResetShopItems() { + const state = { + shopItems: { power_pill: 2 }, + activeBuffs: { clever_brush: { expiresDay: 100 } }, + artifacts: { sword: 3 } + }; + resetShopItems(state); + assert(Object.keys(state.shopItems).length === 0, 'items reset'); + assert(Object.keys(state.activeBuffs).length === 0, 'buffs reset'); + assert(state.artifacts.sword === 3, 'artifacts preserved'); + console.log('testResetShopItems PASS'); +} + +function testApplyArtifactEffects() { + const state = { stats: { body: 10, wisdom: 5 }, artifacts: { sword: 2 } }; + applyArtifactEffects(testConfig, state); + assert(state.stats.body === 30, 'body + 10*2 = 30'); + console.log('testApplyArtifactEffects PASS'); +} + +testCanBuyItem(); +testBuyItem(); +testGetArtifactUpgradeCost(); +testResetShopItems(); +testApplyArtifactEffects(); +console.log('All shopEngine tests PASS'); +``` + +- [ ] **Step 3: 实现 shopEngine.js** + +```javascript +// src/engine/shopEngine.js + +import { evaluateCondition } from './conditionEvaluator.js'; +import { applyEffect } from './effectApplier.js'; +import { canAfford, spendMoney } from './moneySystem.js'; + +let shopConfig = null; + +export function setShopConfig(config) { + shopConfig = config; +} + +export function getShopConfig() { + return shopConfig; +} + +function findEntry(config, id) { + for (const section of ['items', 'buffs', 'artifacts']) { + const entry = config[section]?.find(e => e.id === id); + if (entry) return { ...entry, section }; + } + return null; +} + +// 检查是否满足购买条件(解锁条件 + 银两) +export function canBuyItem(config, state, itemId) { + const entry = findEntry(config, itemId); + if (!entry) return false; + + // 检查解锁条件 + if (entry.unlockConditions && entry.unlockConditions.length > 0) { + if (!evaluateCondition({ type: 'and', conditions: entry.unlockConditions }, state)) { + return false; + } + } + + // 检查银两 + const cost = entry.section === 'artifacts' + ? getArtifactUpgradeCost(config, itemId, state.artifacts[itemId] || 0) + : entry.cost; + return canAfford(state, cost); +} + +// 购买物品/Buff/升级神器 +export function buyItem(config, state, itemId) { + const entry = findEntry(config, itemId); + if (!entry) return false; + + if (!canBuyItem(config, state, itemId)) return false; + + const cost = entry.section === 'artifacts' + ? getArtifactUpgradeCost(config, itemId, state.artifacts[itemId] || 0) + : entry.cost; + + if (!spendMoney(state, cost)) return false; + + // 应用效果 + if (entry.effects) { + for (const effect of entry.effects) { + applyEffect(effect, state); + } + } + + // 记录购买 + if (entry.section === 'items') { + if (!state.shopItems) state.shopItems = {}; + state.shopItems[itemId] = (state.shopItems[itemId] || 0) + 1; + } else if (entry.section === 'buffs') { + if (!state.activeBuffs) state.activeBuffs = {}; + state.activeBuffs[itemId] = { expiresDay: -1 }; // 永久(本轮) + } else if (entry.section === 'artifacts') { + if (!state.artifacts) state.artifacts = {}; + state.artifacts[itemId] = (state.artifacts[itemId] || 0) + 1; + } + + return true; +} + +// 获取神器升级所需银两 +export function getArtifactUpgradeCost(config, artifactId, currentLevel) { + const entry = config.artifacts?.find(a => a.id === artifactId); + if (!entry) return Infinity; + return Math.floor(entry.baseCost * Math.pow(entry.costGrowth, currentLevel)); +} + +// 每轮重置:清除物品和Buff,保留神器 +export function resetShopItems(state) { + state.shopItems = {}; + state.activeBuffs = {}; + // artifacts 保留 +} + +// 应用所有神器效果(通常在每轮开始时调用) +export function applyArtifactEffects(config, state) { + if (!config || !state.artifacts) return; + for (const artifactId of Object.keys(state.artifacts)) { + const entry = config.artifacts?.find(a => a.id === artifactId); + if (!entry || !entry.effects) continue; + const level = state.artifacts[artifactId]; + for (const effect of entry.effects) { + // 神器效果通常乘以等级 + const scaledEffect = { ...effect }; + if (effect.value !== undefined) { + scaledEffect.value = effect.value * level; + } + applyEffect(scaledEffect, state); + } + } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node test/shopEngine.test.js` +Expected: `All shopEngine tests PASS` + +- [ ] **Step 5: Commit** + +```bash +git add src/config/shop.json src/engine/shopEngine.js test/shopEngine.test.js +git commit -m "feat: add shop engine with 3-tab support and artifact upgrades" +``` + +--- + +## Task 7: 事件引擎 (eventEngine.js) + +**Files:** +- Create: `src/engine/eventEngine.js` +- Create: `src/config/events.json` +- Test: `test/eventEngine.test.js` + +**说明:** 支持三种触发方式:random(按密度池)、age(年龄触发)、flag(Flag变化)。选择事件有requirement过滤。 + +- [ ] **Step 1: 写事件配置 events.json** + +```json +{ + "events": [ + { + "id": "daily_training", + "trigger": { "type": "random", "pool": "normal", "weight": 100 }, + "text": "你进行了日常训练,感觉身体强壮了一些。", + "effects": [ + { "type": "addStat", "stat": "body", "value": 1 } + ] + }, + { + "id": "study_hard", + "trigger": { "type": "random", "pool": "normal", "weight": 80 }, + "text": "你认真读书,略有领悟。", + "effects": [ + { "type": "addStat", "stat": "wisdom", "value": 1 } + ] + }, + { + "id": "invasion_military_40", + "trigger": { "type": "age", "value": 40 }, + "isChoice": true, + "title": "蛮族入侵", + "text": "北方蛮族大举南下,战火蔓延到你的家乡。", + "choices": [ + { + "text": "组织乡勇抵抗", + "requirements": [ + { "type": "careerLevel", "careerId": "soldier", "op": ">=", "value": 50 } + ], + "effects": [ + { "type": "setFlag", "flag": "invasion_military_resisted", "value": true }, + { "type": "addLog", "text": "你率领乡勇击退了蛮族的先锋部队!", "logType": "major" } + ] + }, + { + "text": "逃往南方", + "effects": [ + { "type": "addStat", "stat": "body", "value": -3 }, + { "type": "addLog", "text": "你随着难民潮逃往南方,一路上风餐露宿。", "logType": "normal" } + ] + } + ] + }, + { + "id": "invasion_spiritual_45", + "trigger": { "type": "age", "value": 45 }, + "isChoice": true, + "title": "天魔降世", + "text": "天空裂开一道缝隙,天魔之气倾泻而下。", + "choices": [ + { + "text": "以道心镇压", + "requirements": [ + { "type": "careerLevel", "careerId": "taoist_priest", "op": ">=", "value": 50 } + ], + "effects": [ + { "type": "setFlag", "flag": "invasion_spiritual_resisted", "value": true }, + { "type": "addLog", "text": "你运转道心,暂时镇压了天魔之气。", "logType": "major" } + ] + }, + { + "text": "躲入地窖", + "effects": [ + { "type": "addStat", "stat": "wisdom", "value": -2 }, + { "type": "addLog", "text": "你躲入地窖,瑟瑟发抖地等待灾难过去。", "logType": "normal" } + ] + } + ] + }, + { + "id": "invasion_political_50", + "trigger": { "type": "age", "value": 50 }, + "isChoice": true, + "title": "王朝崩坏", + "text": "朝廷内斗激烈,政令不出京城,天下大乱。", + "choices": [ + { + "text": "辅佐明主", + "requirements": [ + { "type": "stat", "stat": "intelligence", "op": ">=", "value": 50 } + ], + "effects": [ + { "type": "setFlag", "flag": "invasion_political_resisted", "value": true }, + { "type": "addLog", "text": "你运筹帷幄,辅佐一方势力稳住了局面。", "logType": "major" } + ] + }, + { + "text": "独善其身", + "effects": [ + { "type": "addStat", "stat": "intelligence", "value": -2 }, + { "type": "addLog", "text": "你选择闭门不出,不问政事。", "logType": "normal" } + ] + } + ] + }, + { + "id": "final_crisis_60", + "trigger": { "type": "age", "value": 60 }, + "isChoice": true, + "title": "最终危机", + "text": "三相汇聚,天地变色。你必须做出最终的选择。", + "choices": [ + { + "text": "以将军之道破局", + "requirements": [ + { "type": "careerLevel", "careerId": "general", "op": ">=", "value": 1 } + ], + "effects": [ + { "type": "setFlag", "flag": "breakthrough_general", "value": true }, + { "type": "addLog", "text": "你以无敌军阵,破开天地枷锁!", "logType": "major" } + ] + }, + { + "text": "以宰相之道破局", + "requirements": [ + { "type": "careerLevel", "careerId": "minister", "op": ">=", "value": 1 } + ], + "effects": [ + { "type": "setFlag", "flag": "breakthrough_minister", "value": true }, + { "type": "addLog", "text": "你以无双智计,重整天地秩序!", "logType": "major" } + ] + }, + { + "text": "以修仙之道破局", + "requirements": [ + { "type": "careerLevel", "careerId": "immortal", "op": ">=", "value": 1 } + ], + "effects": [ + { "type": "setFlag", "flag": "breakthrough_immortal", "value": true }, + { "type": "addLog", "text": "你以无上道心,超脱轮回之外!", "logType": "major" } + ] + }, + { + "text": "无力回天", + "effects": [ + { "type": "die", "reason": "三相汇聚,无力回天" } + ] + } + ] + } + ] +} +``` + +- [ ] **Step 2: 写测试文件** + +```javascript +// test/eventEngine.test.js +import { setEventConfig, checkAgeEvents, pickRandomEvent, getAvailableChoices } from '../src/engine/eventEngine.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +const testConfig = { + events: [ + { + id: 'test_random', + trigger: { type: 'random', pool: 'normal', weight: 100 }, + text: 'random event', + effects: [] + }, + { + id: 'test_age_20', + trigger: { type: 'age', value: 20 }, + text: 'age event', + effects: [{ type: 'addStat', stat: 'body', value: 1 }] + }, + { + id: 'test_choice', + trigger: { type: 'random', pool: 'major', weight: 50 }, + isChoice: true, + text: 'choice event', + choices: [ + { text: 'A', requirements: [], effects: [] }, + { text: 'B', requirements: [{ type: 'stat', stat: 'body', op: '>=', value: 50 }], effects: [] } + ] + } + ] +}; + +setEventConfig(testConfig); + +function testCheckAgeEvents() { + const state = { age: 20, triggeredEvents: new Set(), stats: { body: 10 } }; + const events = checkAgeEvents(testConfig, state); + assert(events.length === 1, 'one age event at 20'); + assert(events[0].id === 'test_age_20', 'correct event'); + console.log('testCheckAgeEvents PASS'); +} + +function testCheckAgeEventsOnce() { + const state = { age: 20, triggeredEvents: new Set(['test_age_20']), stats: { body: 10 } }; + const events = checkAgeEvents(testConfig, state); + assert(events.length === 0, 'age event already triggered'); + console.log('testCheckAgeEventsOnce PASS'); +} + +function testPickRandomEvent() { + const state = { triggeredEvents: new Set(), stats: { body: 10 } }; + const event = pickRandomEvent(testConfig, state, 'normal'); + assert(event && event.id === 'test_random', 'picked random event'); + console.log('testPickRandomEvent PASS'); +} + +function testGetAvailableChoices() { + const state = { stats: { body: 30 } }; + const event = testConfig.events.find(e => e.id === 'test_choice'); + const choices = getAvailableChoices(event, state); + assert(choices.length === 1, 'only one choice available (body < 50)'); + assert(choices[0].text === 'A', 'choice A available'); + console.log('testGetAvailableChoices PASS'); +} + +testCheckAgeEvents(); +testCheckAgeEventsOnce(); +testPickRandomEvent(); +testGetAvailableChoices(); +console.log('All eventEngine tests PASS'); +``` + +- [ ] **Step 3: 实现 eventEngine.js** + +```javascript +// src/engine/eventEngine.js + +import { evaluateCondition } from './conditionEvaluator.js'; + +let eventConfig = null; + +export function setEventConfig(config) { + eventConfig = config; +} + +export function getEventConfig() { + return eventConfig; +} + +// 检查年龄触发的事件(只触发一次) +export function checkAgeEvents(config, state) { + const triggered = []; + for (const event of config.events) { + if (event.trigger?.type !== 'age') continue; + if (state.triggeredEvents.has(event.id)) continue; + if (state.age === event.trigger.value) { + triggered.push(event); + state.triggeredEvents.add(event.id); + } + } + return triggered; +} + +// 从随机池中抽取一个事件 +export function pickRandomEvent(config, state, pool) { + const candidates = config.events.filter(e => { + if (e.trigger?.type !== 'random') return false; + if (e.trigger.pool !== pool) return false; + if (state.triggeredEvents.has(e.id)) return false; + return true; + }); + + if (candidates.length === 0) return null; + + const totalWeight = candidates.reduce((sum, e) => sum + (e.trigger.weight || 1), 0); + let rand = Math.random() * totalWeight; + for (const event of candidates) { + rand -= event.trigger.weight || 1; + if (rand <= 0) return event; + } + return candidates[candidates.length - 1]; +} + +// 获取选择事件中当前可选的选项 +export function getAvailableChoices(event, state) { + if (!event.choices) return []; + return event.choices.filter(choice => { + if (!choice.requirements || choice.requirements.length === 0) return true; + return evaluateCondition({ type: 'and', conditions: choice.requirements }, state); + }); +} + +// 执行事件(非选择事件) +export function executeEvent(event, state, applyEffectFn) { + if (event.effects) { + for (const effect of event.effects) { + applyEffectFn(effect, state); + } + } + if (event.id) { + state.triggeredEvents.add(event.id); + } +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `node test/eventEngine.test.js` +Expected: `All eventEngine tests PASS` + +- [ ] **Step 5: Commit** + +```bash +git add src/config/events.json src/engine/eventEngine.js test/eventEngine.test.js +git commit -m "feat: add event engine with age trigger, random pool, choice filtering" +``` + +--- + +## Task 8: 入侵与死亡引擎 (invasionEngine.js + deathEngine.js) + +**Files:** +- Create: `src/engine/invasionEngine.js` +- Create: `src/engine/deathEngine.js` +- Test: `test/invasionEngine.test.js` +- Test: `test/deathEngine.test.js` + +**说明:** invasionEngine 负责检查入侵触发状态、破局判定。deathEngine 负责死亡检查和重生结算。 + +- [ ] **Step 1: 实现 invasionEngine.js** + +```javascript +// src/engine/invasionEngine.js + +const INVASION_KEYS = ['military_40', 'spiritual_45', 'political_50', 'final_60']; + +export function getInvasionStatus(state) { + return INVASION_KEYS.map(key => ({ + key, + triggered: state.invasionsTriggered[key] || false, + })); +} + +export function isAllInvasionsResisted(state) { + return ( + state.worldFlags.invasion_military_resisted && + state.worldFlags.invasion_spiritual_resisted && + state.worldFlags.invasion_political_resisted + ); +} + +export function checkBreakthrough(state) { + if (state.worldFlags.breakthrough_general) return 'general'; + if (state.worldFlags.breakthrough_minister) return 'minister'; + if (state.worldFlags.breakthrough_immortal) return 'immortal'; + return null; +} +``` + +- [ ] **Step 2: 写入侵测试** + +```javascript +// test/invasionEngine.test.js +import { getInvasionStatus, isAllInvasionsResisted, checkBreakthrough } from '../src/engine/invasionEngine.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testInvasionStatus() { + const state = { + invasionsTriggered: { military_40: true, spiritual_45: false, political_50: false, final_60: false } + }; + const status = getInvasionStatus(state); + assert(status[0].triggered === true, 'military triggered'); + assert(status[1].triggered === false, 'spiritual not triggered'); + console.log('testInvasionStatus PASS'); +} + +function testAllResisted() { + const state = { + worldFlags: { + invasion_military_resisted: true, + invasion_spiritual_resisted: true, + invasion_political_resisted: true + } + }; + assert(isAllInvasionsResisted(state) === true, 'all resisted'); + + state.worldFlags.invasion_military_resisted = false; + assert(isAllInvasionsResisted(state) === false, 'not all resisted'); + console.log('testAllResisted PASS'); +} + +function testBreakthrough() { + const state = { worldFlags: { breakthrough_general: true } }; + assert(checkBreakthrough(state) === 'general', 'general breakthrough'); + console.log('testBreakthrough PASS'); +} + +testInvasionStatus(); +testAllResisted(); +testBreakthrough(); +console.log('All invasionEngine tests PASS'); +``` + +- [ ] **Step 3: 实现 deathEngine.js** + +```javascript +// src/engine/deathEngine.js + +import { applyEffect } from './effectApplier.js'; + +export function checkDeath(state, eventConfig) { + if (!state.alive) return true; + + // 年龄死亡 + if (state.age >= 100) { + applyEffect({ type: 'die', reason: '寿终正寝' }, state); + return true; + } + if (state.age >= 60 && Math.random() < 0.1) { + applyEffect({ type: 'die', reason: '年老体衰' }, state); + return true; + } + + // 属性死亡 + if (state.stats.body <= 0) { + applyEffect({ type: 'die', reason: '体弱身亡' }, state); + return true; + } + + // 入侵死亡(未抵抗的入侵到年龄后) + if (state.age >= 45 && !state.worldFlags.invasion_military_resisted) { + if (Math.random() < 0.05) { + applyEffect({ type: 'die', reason: '死于蛮族入侵' }, state); + return true; + } + } + if (state.age >= 50 && !state.worldFlags.invasion_spiritual_resisted) { + if (Math.random() < 0.05) { + applyEffect({ type: 'die', reason: '被天魔吞噬' }, state); + return true; + } + } + if (state.age >= 55 && !state.worldFlags.invasion_political_resisted) { + if (Math.random() < 0.05) { + applyEffect({ type: 'die', reason: '死于战乱' }, state); + return true; + } + } + + return false; +} + +// 死亡结算:计算元经验、解锁新词条等 +export function processDeath(state, careerConfig, talentConfig) { + const metaGains = {}; + for (const [careerId, career] of Object.entries(state.careers)) { + const cfg = careerConfig.careers.find(c => c.id === careerId); + if (!cfg) continue; + + // 总经验 = 当前经验 + 已升级的经验 + let totalExp = career.exp; + for (let i = 0; i < career.level; i++) { + totalExp += Math.floor(cfg.expCurve.base * Math.pow(cfg.expCurve.factor, i)); + } + + const gain = Math.floor(totalExp * 0.1); + state.metaExp[careerId] = (state.metaExp[careerId] || 0) + gain; + metaGains[careerId] = gain; + } + + return { metaGains }; +} +``` + +- [ ] **Step 4: 写死亡测试** + +```javascript +// test/deathEngine.test.js +import { checkDeath, processDeath } from '../src/engine/deathEngine.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +function testAgeDeath() { + const state = { alive: true, age: 100, stats: { body: 10 }, worldFlags: {} }; + assert(checkDeath(state) === true, 'age 100 dies'); + assert(state.alive === false, 'alive false'); + console.log('testAgeDeath PASS'); +} + +function testBodyDeath() { + const state = { alive: true, age: 30, stats: { body: 0 }, worldFlags: {} }; + assert(checkDeath(state) === true, 'body 0 dies'); + console.log('testBodyDeath PASS'); +} + +function testSurvive() { + const state = { alive: true, age: 30, stats: { body: 10 }, worldFlags: {} }; + assert(checkDeath(state) === false, 'young and healthy survives'); + console.log('testSurvive PASS'); +} + +function testProcessDeath() { + const state = { + careers: { student: { level: 2, exp: 5 } }, + metaExp: {} + }; + const careerConfig = { + careers: [ + { id: 'student', expCurve: { base: 10, factor: 1.15 } } + ] + }; + const result = processDeath(state, careerConfig, {}); + assert(state.metaExp.student > 0, 'meta exp gained'); + console.log('testProcessDeath PASS'); +} + +testAgeDeath(); +testBodyDeath(); +testSurvive(); +testProcessDeath(); +console.log('All deathEngine tests PASS'); +``` + +- [ ] **Step 5: 运行测试确认通过** + +Run: +```bash +node test/invasionEngine.test.js +node test/deathEngine.test.js +``` +Expected: `All invasionEngine tests PASS` and `All deathEngine tests PASS` + +- [ ] **Step 6: Commit** + +```bash +git add src/engine/invasionEngine.js src/engine/deathEngine.js test/invasionEngine.test.js test/deathEngine.test.js +git commit -m "feat: add invasion and death engines" +``` + +--- + +## Task 9: 游戏主循环 (tickLoop.js) + +**Files:** +- Create: `src/engine/tickLoop.js` +- Test: `test/tickLoop.test.js` + +**说明:** 整合所有引擎,每tick执行完整的游戏逻辑。 + +- [ ] **Step 1: 写测试文件** + +```javascript +// test/tickLoop.test.js +import { tick } from '../src/engine/tickLoop.js'; +import { setCareerConfig } from '../src/engine/careerEngine.js'; +import { setEventConfig } from '../src/engine/eventEngine.js'; +import { setShopConfig } from '../src/engine/shopEngine.js'; +import { createInitialState } from '../src/engine/state.js'; + +function assert(cond, msg) { if (!cond) throw new Error(msg); } + +const careerCfg = { + careers: [ + { id: 'student', name: '学童', type: 'scholar', unlockConditions: [{ type: 'age', op: '>=', value: 6 }], dailyIncome: 1, expCurve: { base: 10, factor: 1.15 } } + ] +}; + +const eventCfg = { + events: [ + { id: 'daily_training', trigger: { type: 'random', pool: 'normal', weight: 100 }, text: 'train', effects: [{ type: 'addStat', stat: 'body', value: 1 }] } + ] +}; + +const shopCfg = { items: [], buffs: [], artifacts: [] }; + +setCareerConfig(careerCfg); +setEventConfig(eventCfg); +setShopConfig(shopCfg); + +function testTickAdvancesDay() { + const state = createInitialState(); + state.age = 10; + state.stats = { body: 10, wisdom: 10, charm: 10, destiny: 10, business: 10, intelligence: 10 }; + tick(state, 1, careerCfg, eventCfg, shopCfg); + assert(state.day === 1, 'day advanced'); + console.log('testTickAdvancesDay PASS'); +} + +function testTickUnlocksCareer() { + const state = createInitialState(); + state.age = 10; + state.stats = { body: 10, wisdom: 10, charm: 10, destiny: 10, business: 10, intelligence: 10 }; + tick(state, 1, careerCfg, eventCfg, shopCfg); + assert(state.careers.student, 'student unlocked at age 10'); + console.log('testTickUnlocksCareer PASS'); +} + +function testTickIncome() { + const state = createInitialState(); + state.age = 10; + state.stats = { body: 10, wisdom: 10, charm: 10, destiny: 10, business: 0, intelligence: 10 }; + state.careers.student = { level: 0, exp: 0 }; + const moneyBefore = state.money; + tick(state, 1, careerCfg, eventCfg, shopCfg); + assert(state.money > moneyBefore, 'money increased'); + console.log('testTickIncome PASS'); +} + +testTickAdvancesDay(); +testTickUnlocksCareer(); +testTickIncome(); +console.log('All tickLoop tests PASS'); +``` + +- [ ] **Step 2: 实现 tickLoop.js** + +```javascript +// src/engine/tickLoop.js + +import { checkUnlocks, calcDailyIncome, calcDailyExp, checkLevelUp } from './careerEngine.js'; +import { applyDailyIncome } from './moneySystem.js'; +import { checkAgeEvents, pickRandomEvent, executeEvent } from './eventEngine.js'; +import { applyArtifactEffects } from './shopEngine.js'; +import { checkDeath } from './deathEngine.js'; +import { applyEffect } from './effectApplier.js'; + +export function tick(state, days = 1, careerConfig, eventConfig, shopConfig) { + if (!state.alive || state.paused) return; + + for (let i = 0; i < days; i++) { + state.day++; + + // 年龄增长 + if (state.day % 365 === 0) { + state.year++; + state.age++; + } + + // 应用神器效果(每轮开始时) + applyArtifactEffects(shopConfig, state); + + // 检查职业解锁 + if (careerConfig) checkUnlocks(careerConfig, state); + + // 银两结算 + if (careerConfig) { + const income = calcDailyIncome(careerConfig, state); + applyDailyIncome(state, income); + } + + // 职业经验结算 + if (careerConfig) { + for (const careerId of Object.keys(state.careers)) { + const exp = calcDailyExp(careerConfig, state, careerId); + if (state.careers[careerId]) { + state.careers[careerId].exp += exp; + } + } + checkLevelUp(careerConfig, state); + } + + // 年龄触发事件(入侵等) + if (eventConfig) { + const ageEvents = checkAgeEvents(eventConfig, state); + for (const event of ageEvents) { + if (event.isChoice) { + // 选择事件暂停游戏,交给UI处理 + state.paused = true; + state.currentEvent = event; + return; // 跳出循环,等待玩家选择 + } else { + executeEvent(event, state, applyEffect); + } + } + } + + // 随机事件(低密度,不是每天都触发) + if (eventConfig && Math.random() < 0.3) { + const event = pickRandomEvent(eventConfig, state, 'normal'); + if (event && !event.isChoice) { + executeEvent(event, state, applyEffect); + } + } + + // 检查死亡 + if (checkDeath(state)) { + return; + } + } +} + +// 启动/停止游戏循环 +let tickInterval = null; + +export function startGameLoop(state, careerConfig, eventConfig, shopConfig, onTick) { + if (tickInterval) return; + + const SPEEDS = [1, 10, 100, 1000]; + const speed = SPEEDS[state.speed] || 1; + const intervalMs = 1000 / speed; + + tickInterval = setInterval(() => { + if (!state.paused && state.alive) { + tick(state, 1, careerConfig, eventConfig, shopConfig); + if (onTick) onTick(); + } + }, intervalMs); +} + +export function stopGameLoop() { + if (tickInterval) { + clearInterval(tickInterval); + tickInterval = null; + } +} + +export function setSpeed(state, level, careerConfig, eventConfig, shopConfig, onTick) { + state.speed = level; + stopGameLoop(); + startGameLoop(state, careerConfig, eventConfig, shopConfig, onTick); +} +``` + +- [ ] **Step 3: 运行测试确认通过** + +Run: `node test/tickLoop.test.js` +Expected: `All tickLoop tests PASS` + +- [ ] **Step 4: Commit** + +```bash +git add src/engine/tickLoop.js test/tickLoop.test.js +git commit -m "feat: add game tick loop integrating all engines" +``` + +--- + +## Task 10: 配置文件框架 (identities.json + talents.json) + +**Files:** +- Create: `src/config/identities.json` +- Create: `src/config/talents.json` + +**说明:** 创建配置文件框架,后续内容填充。 + +- [ ] **Step 1: 写 identities.json** + +```json +{ + "identities": [ + { + "id": "taoist_orphan", + "name": "道观弃婴", + "description": "被遗弃在道观门口的婴儿,由道士抚养长大。", + "startingStats": { + "body": 3, + "wisdom": 5, + "charm": 2, + "destiny": 4, + "business": 0, + "intelligence": 3 + }, + "startingItems": ["taoist_license"], + "unlockConditions": [ + { "type": "reincarnation", "op": ">=", "value": 0 } + ] + }, + { + "id": "peasant", + "name": "农家子", + "description": "普通农户家的孩子。", + "startingStats": { + "body": 4, + "wisdom": 3, + "charm": 3, + "destiny": 3, + "business": 1, + "intelligence": 2 + }, + "startingItems": [], + "unlockConditions": [] + }, + { + "id": "merchant_son", + "name": "商贾之子", + "description": "商人家庭出身,从小耳濡目染经商之道。", + "startingStats": { + "body": 2, + "wisdom": 4, + "charm": 4, + "destiny": 3, + "business": 5, + "intelligence": 4 + }, + "startingItems": [], + "unlockConditions": [ + { "type": "reincarnation", "op": ">=", "value": 1 } + ] + } + ] +} +``` + +- [ ] **Step 2: 写 talents.json** + +```json +{ + "talents": [ + { + "id": "sword_bone", + "name": "剑骨", + "type": "talent", + "description": "天生剑骨,军事职业经验+20%", + "effects": [ + { "type": "addStat", "stat": "body", "value": 5 } + ], + "unlockConditions": [ + { "type": "careerLevel", "careerId": "soldier", "op": ">=", "value": 100 } + ] + }, + { + "id": "spirit_root", + "name": "灵根", + "type": "talent", + "description": "身怀灵根,修仙职业经验+20%", + "effects": [ + { "type": "addStat", "stat": "wisdom", "value": 5 } + ], + "unlockConditions": [ + { "type": "careerLevel", "careerId": "taoist_priest", "op": ">=", "value": 100 } + ] + }, + { + "id": "eidetic_memory", + "name": "过目不忘", + "type": "talent", + "description": "记忆力超群,书生职业经验+20%", + "effects": [ + { "type": "addStat", "stat": "wisdom", "value": 3 }, + { "type": "addStat", "stat": "intelligence", "value": 3 } + ], + "unlockConditions": [ + { "type": "careerLevel", "careerId": "student", "op": ">=", "value": 200 } + ] + } + ] +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/config/identities.json src/config/talents.json +git commit -m "feat: add identity and talent config frameworks" +``` + +--- + +## Task 11: 主入口 (main.js) + +**Files:** +- Modify: `src/main.js` + +**说明:** 重写main.js,负责加载配置、初始化状态、绑定UI事件、启动游戏循环。 + +- [ ] **Step 1: 重写 main.js** + +```javascript +// src/main.js + +import { createInitialState, resetForRebirth } from './engine/state.js'; +import { setCareerConfig } from './engine/careerEngine.js'; +import { setEventConfig } from './engine/eventEngine.js'; +import { setShopConfig } from './engine/shopEngine.js'; +import { startGameLoop, stopGameLoop, setSpeed, tick } from './engine/tickLoop.js'; +import { applyEffect } from './engine/effectApplier.js'; +import { processDeath } from './engine/deathEngine.js'; +import { initRenderer, renderAll, showDeathScreen, showChoiceEvent, hideChoiceEvent } from './ui/renderer.js'; + +// 配置对象(由 build 时内联或运行时 fetch) +let careerConfig = null; +let eventConfig = null; +let shopConfig = null; +let identityConfig = null; +let talentConfig = null; + +// 全局状态 +const state = createInitialState(); + +// 加载所有配置 +async function loadConfigs() { + const [careers, events, shop, identities, talents] = await Promise.all([ + fetch('./src/config/careers.json').then(r => r.json()), + fetch('./src/config/events.json').then(r => r.json()), + fetch('./src/config/shop.json').then(r => r.json()), + fetch('./src/config/identities.json').then(r => r.json()), + fetch('./src/config/talents.json').then(r => r.json()), + ]); + + careerConfig = careers; + eventConfig = events; + shopConfig = shop; + identityConfig = identities; + talentConfig = talents; + + setCareerConfig(careerConfig); + setEventConfig(eventConfig); + setShopConfig(shopConfig); +} + +// 初始化新游戏 +function startNewGame() { + // 重置状态(保留跨轮数据) + resetForRebirth(state); + + // 随机选择开局身份 + const availableIdentities = identityConfig.identities.filter(id => { + if (!id.unlockConditions || id.unlockConditions.length === 0) return true; + // 简化:这里需要 evaluateCondition,但 unlockConditions 可能引用未加载的模块 + // 实际实现中需要统一处理 + return true; + }); + const identity = availableIdentities[Math.floor(Math.random() * availableIdentities.length)]; + state.identity = identity.id; + + // 应用身份初始属性 + if (identity.startingStats) { + Object.assign(state.stats, identity.startingStats); + } + if (identity.startingItems) { + for (const itemId of identity.startingItems) { + applyEffect({ type: 'addItem', itemId }, state); + } + } + + // 随机抽取词条(从已解锁池) + // TODO: 实现词条抽取逻辑 + + // 应用神器效果 + // applyArtifactEffects 在 tick 中调用 + + // 启动游戏循环 + startGameLoop(state, careerConfig, eventConfig, shopConfig, () => { + renderAll(state, careerConfig, shopConfig); + }); +} + +// 处理选择事件 +function handleChoice(choiceIndex) { + if (!state.currentEvent) return; + + const choice = state.currentEvent.choices[choiceIndex]; + if (!choice) return; + + // 应用选择效果 + if (choice.effects) { + for (const effect of choice.effects) { + applyEffect(effect, state); + } + } + + // 恢复游戏 + state.paused = false; + state.currentEvent = null; + hideChoiceEvent(); +} + +// 绑定UI事件 +function bindUIEvents() { + // 速度按钮 + document.querySelectorAll('.speed-btn').forEach(btn => { + btn.addEventListener('click', () => { + const level = parseInt(btn.dataset.speed); + setSpeed(state, level, careerConfig, eventConfig, shopConfig, () => { + renderAll(state, careerConfig, shopConfig); + }); + document.querySelectorAll('.speed-btn').forEach(b => b.classList.remove('active')); + btn.classList.add('active'); + }); + }); + + // 暂停按钮 + document.getElementById('pause-btn').addEventListener('click', () => { + state.paused = !state.paused; + }); + + // 选择事件按钮委托 + document.getElementById('event-choices').addEventListener('click', (e) => { + if (e.target.classList.contains('choice-btn')) { + const idx = parseInt(e.target.dataset.index); + handleChoice(idx); + } + }); + + // TODO: 存档/读档、商铺按钮等 +} + +// 检查死亡并处理 +function checkAndHandleDeath() { + if (!state.alive && state.deathReason) { + stopGameLoop(); + processDeath(state, careerConfig, talentConfig); + showDeathScreen(state, careerConfig); + } +} + +// 主初始化 +async function init() { + await loadConfigs(); + initRenderer(); + bindUIEvents(); + startNewGame(); + + // 每帧检查死亡 + setInterval(checkAndHandleDeath, 500); +} + +init().catch(console.error); +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/main.js +git commit -m "feat: rewrite main.js with config-driven initialization" +``` + +--- + +## Task 12: UI渲染器 (renderer.js) + +**Files:** +- Modify: `src/ui/renderer.js` + +**说明:** 重写渲染器,适配新的状态结构。新增商铺面板、神器面板、6属性展示。 + +- [ ] **Step 1: 重写 renderer.js** + +```javascript +// src/ui/renderer.js + +// DOM 缓存 +let els = {}; + +export function initRenderer() { + els = { + reincarnation: document.getElementById('reincarnation'), + year: document.getElementById('year'), + day: document.getElementById('day'), + age: document.getElementById('age'), + money: document.getElementById('money'), + crisis: document.getElementById('crisis'), + stats: { + body: document.getElementById('stat-body'), + wisdom: document.getElementById('stat-wisdom'), + charm: document.getElementById('stat-charm'), + destiny: document.getElementById('stat-destiny'), + business: document.getElementById('stat-business'), + intelligence: document.getElementById('stat-intelligence'), + }, + eventPanel: document.getElementById('event-panel'), + eventTitle: document.getElementById('event-title'), + eventText: document.getElementById('event-text'), + eventChoices: document.getElementById('event-choices'), + logStream: document.getElementById('log-stream'), + careerList: document.getElementById('career-list'), + talentList: document.getElementById('talent-list'), + artifactList: document.getElementById('artifact-list'), + shopPanel: document.getElementById('shop-panel'), + deathScreen: document.getElementById('death-screen'), + deathContent: document.getElementById('death-content'), + }; +} + +export function renderAll(state, careerConfig, shopConfig) { + renderTopBar(state); + renderStats(state); + renderCareers(state, careerConfig); + renderTalents(state); + renderArtifacts(state, shopConfig); + renderLogs(state); + renderShop(state, shopConfig); + renderEventPanel(state); +} + +function renderTopBar(state) { + if (els.reincarnation) els.reincarnation.textContent = state.reincarnation + 1; + if (els.year) els.year.textContent = state.year; + if (els.day) els.day.textContent = state.day; + if (els.age) els.age.textContent = state.age; + if (els.money) els.money.textContent = Math.floor(state.money); +} + +function renderStats(state) { + for (const [key, el] of Object.entries(els.stats)) { + if (el) el.textContent = Math.floor(state.stats[key] || 0); + } +} + +function renderCareers(state, careerConfig) { + if (!els.careerList || !careerConfig) return; + + const items = []; + for (const cfg of careerConfig.careers) { + const career = state.careers[cfg.id]; + if (!career) continue; + + const need = cfg.expCurve.base * Math.pow(cfg.expCurve.factor, career.level); + const pct = Math.min(100, (career.exp / need) * 100); + + items.push(` +
原因:${state.deathReason || '未知'}
+年龄:${state.age}岁
+职业成就:${careerText || '无'}
+ + `; + + els.deathScreen.classList.remove('hidden'); + + document.getElementById('rebirth-btn').addEventListener('click', () => { + els.deathScreen.classList.add('hidden'); + // TODO: 触发重生 + window.location.reload(); // 临时方案 + }); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/ui/renderer.js +git commit -m "feat: rewrite renderer with 6 stats, shop panel, artifact display" +``` + +--- + +## Task 13: HTML 更新(新增6属性、商铺面板、银两显示) + +**Files:** +- Modify: `index.html` + +**说明:** 更新HTML结构,新增6属性展示、银两显示、商铺面板、神器面板。 + +- [ ] **Step 1: 更新 index.html** + +在现有HTML基础上,修改以下部分: + +1. 顶部信息栏增加银两显示: +```html +