Files
spellforge/docs_dev/plans/2026-07-20-mana-system-mvp.md
T
2026-07-20 16:21:16 +08:00

449 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Mana 魔力系统(精实 MVP)实现计划
> **For agentic workers:** 用 superpowers:subagent-driven-development(推荐)或 superpowers:executing-plans 逐任务实现。步骤用 `- [ ]` 复选框跟踪。
**Goal:** 让法术施放消耗魔力、魔力随时间回复、蓝空停施,并有 HUD 蓝条——把 Mana 从 0% 实现变成可玩资源门槛。
**Architecture:** 蓝池状态放 `PlayerStats`(镜像 hp);回蓝由 `PlayerManager._physics_process` 驱动;扣蓝在 `SpellEvaluator.execute_compiled` 主循环 `deck.pop()` 后、`match` 前一次性完成(该处只会遇到真正要执行的节点,被跳过/被消耗的节点不会到达此处);HUD 加蓝条。数值取 `numerical_design §2`
**Tech Stack:** Godot 4.6 (Mono) · GDScript · 纯 JSON 数据驱动 · 验证用 Godot MCP(`execute_game_script`),本项目无单元测试框架。
> **验证约定**:每个任务的"测试"用 MCP 运行时断言。前置:Godot 编辑器开着、MCP 已连;用 `play_scene(main)` 起游戏后 `execute_game_script` 直接调用 autoload(无需进战斗场景,除 Task 6 HUD 需 `SceneManager.start_new_game()`)。跑完 `stop_scene`。
---
### Task 1: CoreDefinition 加 mana 字段 + cores.json 数据 + WandPreset 读取
**Files:**
- Modify: `scripts/domain/spell_system/core_definition.gd`(在 `feature_tags` 后加 2 字段)
- Modify: `scripts/autoloads/wand_preset.gd:26-38``_core_from_dict` 读入)
- Modify: `data/cores.json`(每 Core 加 mana_max/mana_regen
- [ ] **Step 1: CoreDefinition 加字段**
`core_definition.gd` 第 28 行 `@export var feature_tags: int = 0` 之后插入:
```gdscript
## 魔力池(MVP)——由 WandPreset 从 cores.json 读入
@export var base_mana_max: float = 100.0
@export var base_mana_regen: float = 5.0 # 每秒回复
```
- [ ] **Step 2: WandPreset 读取两字段**
`wand_preset.gd` `_core_from_dict()``c.edges = d.get("edges", [])`(第 37 行)之后、`return c` 之前插入:
```gdscript
c.base_mana_max = float(d.get("mana_max", 100.0))
c.base_mana_regen = float(d.get("mana_regen", 5.0))
```
- [ ] **Step 3: cores.json 加数值**
`data/cores.json` 整体替换为(每 Core 加 `mana_max`/`mana_regen`):
```json
{
"wand_basic": { "display_name": "小木法杯", "slot_count": 5, "topology": 0, "cpu_limit": 5, "cast_interval": 0.5, "feature_tags": 0, "mana_max": 100, "mana_regen": 5 },
"wand_fast": { "display_name": "高速法杯", "slot_count": 3, "topology": 0, "cpu_limit": 3, "cast_interval": 0.25, "feature_tags": 0, "mana_max": 60, "mana_regen": 4 },
"wand_memory": { "display_name": "记忆法杖", "slot_count": 6, "topology": 0, "cpu_limit": 6, "cast_interval": 0.5, "feature_tags": 1, "mana_max": 100, "mana_regen": 5 },
"matrix_board": { "display_name": "矩阵板", "slot_count": 8, "topology": 1, "cpu_limit": 8, "cast_interval": 0.5, "feature_tags": 0, "grid_rows": 2, "grid_cols": 4, "mana_max": 120, "mana_regen": 6 },
"circuit_fork": { "display_name": "分叉回路", "slot_count": 3, "topology": 2, "cpu_limit": 5, "cast_interval": 0.6, "feature_tags": 0, "edges": [{"from": 0, "to": 1}, {"from": 0, "to": 2}], "mana_max": 100, "mana_regen": 5 }
}
```
- [ ] **Step 4: 验证(MCP**
`play_scene(main)``execute_game_script`
```gdscript
var c = WandPreset.make_core_by_id("wand_basic")
_mcp_print("basic mana_max=%s regen=%s (expect 100/5)" % [str(c.base_mana_max), str(c.base_mana_regen)])
var f = WandPreset.make_core_by_id("wand_fast")
_mcp_print("fast mana_max=%s regen=%s (expect 60/4)" % [str(f.base_mana_max), str(f.base_mana_regen)])
```
Expected: `basic mana_max=100 regen=5``fast mana_max=60 regen=4``get_editor_errors` count=0。`stop_scene`
- [ ] **Step 5: Commit**
```bash
git add scripts/domain/spell_system/core_definition.gd scripts/autoloads/wand_preset.gd data/cores.json
git commit -m "feat(mana): CoreDefinition 加 base_mana_max/regen + cores.json 数据"
```
---
### Task 2: PlayerStats 蓝池状态与操作
**Files:**
- Modify: `scripts/autoloads/player_stats.gd`(加字段/方法、reset 集成)
- [ ] **Step 1: 加字段**
`player_stats.gd` 第 23 行 `var resistance: float = 0.0` 之后插入:
```gdscript
# ── 魔力(MVP)─────────────────────────────────────────
var mana: float = 100.0
var mana_max: float = 100.0
var mana_regen: float = 5.0 # 每秒回复
```
- [ ] **Step 2: 加方法**
`player_stats.gd` `get_hp_percent()`(第 64-65 行)之后插入:
```gdscript
## 换杖时设定蓝池上限/回速,并把当前蓝夹到 [0, max]
func set_mana_pool(max_v: float, regen: float) -> void:
mana_max = max_v
mana_regen = regen
mana = clampf(mana, 0.0, mana_max)
stats_changed.emit()
## 花费魔力;够则扣除返回 true,否则不变返回 false
func spend_mana(cost: float) -> bool:
if mana < cost:
return false
mana -= cost
stats_changed.emit()
return true
## 每帧回蓝(PlayerManager 驱动)。不 emit stats_changed —— HUD 每帧直接读 mana
## 避免回蓝过程每帧发信号触发商店刷新。
func regen_mana(delta: float) -> void:
if mana >= mana_max:
return
mana = min(mana_max, mana + mana_regen * delta)
```
- [ ] **Step 3: reset_for_run 置满蓝**
`player_stats.gd` `reset_for_run()`(第 70-76 行)改为(加 `mana = mana_max` 一行):
```gdscript
func reset_for_run() -> void:
hp = hp_max
mana = mana_max
gold = 0
xp = 0
level = 1
xp_to_next = xp_for_level(1)
stats_changed.emit()
```
`get_save_data`/`load_save_data` 不动——mana 不入存档,续玩从满蓝开始。)
- [ ] **Step 4: 验证(MCP**
```gdscript
PlayerStats.set_mana_pool(100.0, 5.0)
PlayerStats.mana = 100.0
_mcp_print("spend 30 -> %s, mana=%s (expect true/70)" % [str(PlayerStats.spend_mana(30.0)), str(PlayerStats.mana)])
_mcp_print("spend 80 -> %s, mana=%s (expect false/70)" % [str(PlayerStats.spend_mana(80.0)), str(PlayerStats.mana)])
PlayerStats.regen_mana(1.0)
_mcp_print("after regen 1s -> mana=%s (expect 75)" % str(PlayerStats.mana))
PlayerStats.regen_mana(100.0)
_mcp_print("regen big -> mana=%s (expect capped 100)" % str(PlayerStats.mana))
```
Expected: `true/70``false/70``75``100``get_editor_errors` count=0。
- [ ] **Step 5: Commit**
```bash
git add scripts/autoloads/player_stats.gd
git commit -m "feat(mana): PlayerStats 蓝池状态 + spend/regen/set_pool + reset 置满"
```
---
### Task 3: PlayerManager 换杖置池 + 每帧回蓝
**Files:**
- Modify: `scripts/domain/player_manager.gd:30-33`_physics_process 加回蓝)、`:83-87`equip_wand 置池)
- [ ] **Step 1: equip 时置蓝池**
`player_manager.gd` `equip_wand()`(第 83-87 行)改为:
```gdscript
func equip_wand(core: CoreDefinition, compiled: CompiledDeck) -> void:
_equipped_core = core
_equipped_compiled = compiled
_cast_interval = core.cast_interval if core else 0.5
_cast_timer = 0.0
if core:
PlayerStats.set_mana_pool(core.base_mana_max, core.base_mana_regen)
else:
PlayerStats.set_mana_pool(100.0, 5.0)
```
- [ ] **Step 2: 每帧回蓝**
`player_manager.gd` `_physics_process()`(第 30-33 行)改为(加一行):
```gdscript
func _physics_process(delta: float) -> void:
PlayerStats.regen_mana(delta)
_handle_movement(delta)
_handle_charge_state(delta)
_handle_auto_cast(delta)
```
- [ ] **Step 3: 验证(MCP**
```gdscript
var core = WandPreset.make_core_by_id("matrix_board")
PlayerManager.equip_wand(core, SpellEvaluator.compile_wand(core, []))
_mcp_print("equipped matrix -> mana_max=%s (expect 120)" % str(PlayerStats.mana_max))
PlayerStats.mana = 10.0
_mcp_print("mana set to 10")
```
下一次 `execute_game_script`(间隔真实时间,_physics_process 已在跑回蓝):
```gdscript
_mcp_print("mana after wait -> %s (expect > 10, rising toward 120)" % str(PlayerStats.mana))
```
Expected: `mana_max=120`;第二次打印 mana 明显 > 10。`get_editor_errors` count=0。
- [ ] **Step 4: Commit**
```bash
git add scripts/domain/player_manager.gd
git commit -m "feat(mana): PlayerManager 换杖置蓝池 + 每帧回蓝"
```
---
### Task 4: spells.json 加 mana_cost(权威值 numerical §2
**Files:**
- Modify: `data/spells.json`(每法术 meta 加 `mana_cost`
- [ ] **Step 1: 逐法术加 mana_cost**
在每个法术的 `meta` 对象里加 `"mana_cost": N`(值见下表,取自 `numerical_design §2` + 类型/Tier 缺省规则):
| spell id | mana_cost | 依据 |
| :--- | :--- | :--- |
| action_spark_bolt | 5 | §2 spark_bolt |
| action_energy_orb | 20 | §2 energy_orb |
| action_fire_bolt | 8 | Tier1 元素弹缺省 |
| action_poison_dart | 6 | Tier1 缺省 |
| action_laser_beam | 4 | 低伤高速缺省 |
| action_water_wave | 6 | 共鸣输入/Tier1 缺省 |
| action_chain_bolt | 60 | §2 chain_bolt |
| action_plasma_storm | 0 | 共鸣产物免蓝 |
| action_frost_bolt | 8 | Tier1-2 缺省 |
| action_poison_pool | 30 | zone 缺省 |
| action_summon_turret | 35 | summon 缺省 |
| modifier_damage_plus | 15 | §2 damage_plus |
| modifier_spread_mod | 0 | §2 spread_mod |
| modifier_double_cast | 2 | §2 double_cast |
| modifier_pierce_plus | 8 | 小修正缺省 |
| trigger_on_hit | 10 | §2 trigger_hit |
| logic_every_n_shots | 0 | LOGIC 缺省 |
| logic_if_hp_below | 0 | LOGIC 缺省 |
| logic_if_enemy_nearby | 0 | LOGIC 缺省 |
| logic_loop | 0 | LOGIC 缺省 |
示例(`action_spark_bolt` 一行改后):
```json
"action_spark_bolt": { "type": 0, "display_name": "Spark Bolt", "description": "发射一颗电光飞弹,造成 3 伤害。", "element_tags": [], "meta": { "base_damage": 3.0, "speed": 350.0, "lifetime": 4.0, "radius": 6.0, "damage_type": 0, "mana_cost": 5, "shop_cost": 15 } },
```
- [ ] **Step 2: 验证(MCP**
```gdscript
for id in ["action_spark_bolt", "action_chain_bolt", "modifier_damage_plus", "action_plasma_storm", "logic_loop"]:
var s = SpellRegistry.get_spell(id)
_mcp_print("%s mana_cost=%s" % [id, str(s.meta.get("mana_cost", "MISSING"))])
```
Expected: spark=5、chain=60、damage_plus=15、plasma=0、loop=0(无 MISSING)。`get_editor_errors` count=0JSON 合法)。
- [ ] **Step 3: Commit**
```bash
git add data/spells.json
git commit -m "feat(mana): spells.json 每法术加 mana_costnumerical §2"
```
---
### Task 5: SpellEvaluator 主循环扣蓝门控
**Files:**
- Modify: `scripts/domain/spell_system/spell_evaluator.gd:332``execute_compiled` 主循环,`deck.pop()` 后、`match` 前)
- [ ] **Step 1: 插入扣蓝门控**
`execute_compiled()` 第 332 行 `var node: SpellNode = deck.pop()` 之后、第 333 行 `match node.type:` 之前,插入:
```gdscript
var mana_cost: float = float(node.meta.get("mana_cost", 0.0))
if mana_cost > 0.0 and not PlayerStats.spend_mana(mana_cost):
break # 蓝不足 → 停止本次施法(此节点不执行、剩余跳过)
```
> 注意缩进:该行处于 `while` 循环体内(与 `var node = deck.pop()` 同级,两个 Tab)。**只改 `execute_compiled`,不要动 `execute_sub`**SubPayload on-hit 不扣蓝,符合 MVP)。LOOP 内部节点走 `_run_logic_loop` 不经此处,MVP 不扣蓝(已知简化)。
- [ ] **Step 2: 验证 — 逐节点扣蓝(spec 测试 2)**
```gdscript
var core = WandPreset.make_core_by_id("wand_basic")
PlayerStats.set_mana_pool(100.0, 5.0); PlayerStats.mana = 100.0
var deck = [SpellRegistry.get_spell("modifier_damage_plus"), SpellRegistry.get_spell("action_spark_bolt")]
BulletManager.reset()
SpellEvaluator.execute_compiled(SpellEvaluator.compile_wand(core, deck), 0, Vector2(100,100))
_mcp_print("[T2] mana=%s (expect 80), bullets=%d (expect 1)" % [str(PlayerStats.mana), BulletManager.get_active_count()])
```
Expected: `mana=80, bullets=1`100 15 5)。
- [ ] **Step 3: 验证 — 首节点付不起则不发射(spec 测试 3)**
```gdscript
var core = WandPreset.make_core_by_id("wand_basic")
PlayerStats.set_mana_pool(100.0, 5.0); PlayerStats.mana = 3.0
BulletManager.reset()
SpellEvaluator.execute_compiled(SpellEvaluator.compile_wand(core, [SpellRegistry.get_spell("action_spark_bolt")]), 0, Vector2(100,100))
_mcp_print("[T3] mana=%s (expect 3), bullets=%d (expect 0)" % [str(PlayerStats.mana), BulletManager.get_active_count()])
```
Expected: `mana=3, bullets=0`(spark 花 5,付不起,不发射)。
- [ ] **Step 4: 验证 — 部分施放(spec 测试 4)**
```gdscript
var core = WandPreset.make_core_by_id("wand_basic")
PlayerStats.set_mana_pool(100.0, 5.0); PlayerStats.mana = 8.0
var deck = [SpellRegistry.get_spell("modifier_double_cast"), SpellRegistry.get_spell("action_spark_bolt"), SpellRegistry.get_spell("action_spark_bolt")]
BulletManager.reset()
SpellEvaluator.execute_compiled(SpellEvaluator.compile_wand(core, deck), 0, Vector2(100,100))
_mcp_print("[T4] mana=%s (expect 1), bullets=%d (expect 1)" % [str(PlayerStats.mana), BulletManager.get_active_count()])
```
Expected: `mana=1, bullets=1`double_cast 2→mana6spark1 5→mana1,发射;spark2 付不起→停)。
- [ ] **Step 5: 回归 — 持久内存不受影响**
```gdscript
var core = WandPreset.make_core_by_id("wand_memory")
PlayerStats.set_mana_pool(100.0, 5.0)
var deck = [SpellRegistry.get_spell("logic_every_n_shots"), SpellRegistry.get_spell("action_spark_bolt")]
var cA = SpellEvaluator.compile_wand(core, deck)
var r = []
for i in 4:
PlayerStats.mana = 100.0
BulletManager.reset()
SpellEvaluator.execute_compiled(cA, 0, Vector2(100,100))
r.append(BulletManager.get_active_count())
_mcp_print("[reg] every_n_shots = %s (expect [0,0,1,0])" % str(r))
```
Expected: `[0,0,1,0]`P-S5-03 回归,logic cost 0 不影响)。`get_editor_errors` count=0。
- [ ] **Step 6: Commit**
```bash
git add scripts/domain/spell_system/spell_evaluator.gd
git commit -m "feat(mana): SpellEvaluator 主循环逐节点扣蓝 + 蓝空停施"
```
---
### Task 6: HUD 蓝条
**Files:**
- Modify: `scenes/main/combat_s2.gd`(成员声明区加 `_mana_bar``_setup_hud` 建条;`_refresh_hud` 刷新)
- [ ] **Step 1: 加成员**
`combat_s2.gd` HUD 成员声明区(`_hud_layer` 附近)加:
```gdscript
var _mana_bar: ProgressBar = null
```
- [ ] **Step 2: _setup_hud 建蓝条**
`_setup_hud()` 第 231 行 `_lbl_statuses = make_label.call(...)` 之后插入:
```gdscript
_mana_bar = ProgressBar.new()
_mana_bar.position = Vector2(200, 86)
_mana_bar.custom_minimum_size = Vector2(170, 16)
_mana_bar.size = Vector2(170, 16)
_mana_bar.min_value = 0.0
_mana_bar.show_percentage = false
var mana_fill := StyleBoxFlat.new()
mana_fill.bg_color = Color(0.3, 0.55, 1.0)
_mana_bar.add_theme_stylebox_override("fill", mana_fill)
var mana_bg := StyleBoxFlat.new()
mana_bg.bg_color = Color(0.12, 0.12, 0.22)
_mana_bar.add_theme_stylebox_override("background", mana_bg)
_hud_layer.add_child(_mana_bar)
```
- [ ] **Step 3: _refresh_hud 刷新蓝条**
`_refresh_hud()`(第 316 行起)末尾加:
```gdscript
if _mana_bar:
_mana_bar.max_value = PlayerStats.mana_max
_mana_bar.value = PlayerStats.mana
```
- [ ] **Step 4: 验证(MCP,需进战斗场景)**
`play_scene(main)``execute_game_script`: `SceneManager.start_new_game()`。下一次调用:
```gdscript
var cs = get_tree().current_scene
_mcp_print("scene=%s bar=%s" % [cs.name, str(cs._mana_bar != null)])
PlayerStats.mana = PlayerStats.mana_max * 0.4
_mcp_print("mana set to 40%%: value=%s max=%s" % [str(cs._mana_bar.value), str(cs._mana_bar.max_value)])
```
然后 `get_game_screenshot` 确认 HUD 内有蓝色条、约 40% 充满。Expected: `bar=true`,蓝条可见。`get_editor_errors` count=0。`stop_scene`
- [ ] **Step 5: Commit**
```bash
git add scenes/main/combat_s2.gd
git commit -m "feat(mana): HUD 蓝条(ProgressBar,每帧刷新)"
```
---
### Task 7: 更新文档 Mana 现状 callout 为「已实现 MVP」
**Files:**
- Modify: `docs/design/game_design.md`(§4.1 Mana callout
- Modify: `docs_dev/doc_code_audit_2026-07-20.md`Mana 未实现条目)
- [ ] **Step 1: game_design §4.1 callout**
`docs/design/game_design.md` §4.1 的现状 callout(含"Mana 魔力系统完全未实现")替换为:
```markdown
> **⚠️ 实现现状 (2026-07-20 审计 → MVP 已实现)**Mana 精实 MVP 已落地——`PlayerStats` 蓝池(`mana`/`mana_max`/`mana_regen`),`CoreDefinition.base_mana_max/regen`cores.json),每法术 `mana_cost`numerical §2),`SpellEvaluator` 逐节点扣蓝 + 蓝空停施,HUD 蓝条。**暂缺**:递增蓝耗、`infinite_spells` 扣血、全局+法杖取 Min、cast_delay。详见 `docs_dev/specs/2026-07-20-mana-system-mvp-design.md`。
```
- [ ] **Step 2: 审计报告去掉 Mana 未实现**
`docs_dev/doc_code_audit_2026-07-20.md` 中,把「Mana 系统完全未实现」相关条目(C 节 🟠 未实现里的 Mana 行、G.2 未实现清单里的 Mana)标注为「✅ 已实现 MVP (2026-07-20)」。
- [ ] **Step 3: 验证**
`git grep -n "Mana 魔力系统完全未实现" docs/` → 应无结果(已被替换)。
- [ ] **Step 4: Commit**
```bash
git add docs/design/game_design.md docs_dev/doc_code_audit_2026-07-20.md
git commit -m "docs(mana): Mana 现状 callout 更新为已实现(MVP)"
```
---
### Task 8: 全量回归验收
**Files:** 无(纯验证)
- [ ] **Step 1: 重载 + 0 错误**
`reload_project``get_editor_errors` → count=0。
- [ ] **Step 2: 跑完整 6 项 spec 测试**
`play_scene(main)`,逐条 `execute_game_script` 跑 Task 5 的 T2/T3/T4/回归 + Task 2 的 spend/regen + Task 3 的换杖置池,确认全部期望值。
- [ ] **Step 3: 端到端手感**
`SceneManager.start_new_game()` 进战斗,观察:连打 spark_bolt 时蓝条下降、停火后回满;换 `wand_fast`max 60)蓝条上限变小。`get_game_screenshot` 存证。`stop_scene`
- [ ] **Step 4: 完成定义核对(DoD,见 spec §8)**
逐条确认 spec §8 完成定义已满足。
- [ ] **Step 5: Commit(如有文档收尾)**
```bash
git commit --allow-empty -m "chore(mana): MVP 验收通过(Godot 4.6 运行时实测)"
```
---
## 自检结论
- **Spec 覆盖**:§1 范围→Task1-6;§2 组件→Task1(Core)/2(PlayerStats)/3(PlayerManager)/5(SpellEvaluator)/6(HUD);§4 数值→Task1/4;§5 边界→Task2(reset)/5(停施);§6 测试→Task5/8。无遗漏。
- **无占位**:所有步骤含精确文件/行号/完整代码/期望输出。
- **类型一致**`base_mana_max`/`base_mana_regen`Core)、`mana`/`mana_max`/`mana_regen` + `set_mana_pool`/`spend_mana`/`regen_mana`PlayerStats)全程一致;`meta.mana_cost` 键名一致。