Files
spellforge/docs_dev/plans/2026-07-24-element-resistance.md
T

295 lines
13 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.
# 元素抗性(Element ResistanceImplementation Plan
> **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:** 让敌人对不同元素有差异化抗性/弱点,激活当前的死代码抗性维度,使元素类伤害产生真实差异。
**Architecture:** `enemies.json` 每型可选 `resistances{元素名:-0.9~0.9}``EnemyManager._RESIST`(type→PackedFloat32Array[6]) 加载 → `apply_damage_from_context``ctx.damage_type` 查表传入 `calc_damage(0, res)`。护甲逻辑不动(仍在 `apply_damage` 内);物理(0) 无抗性;越界安全回退 0。设计器 enemy_tab 加 6×5 抗性网格。
**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro 运行时实测(无 pytest:核心逻辑经运行中战斗场景 `execute_game_script` 断言,编辑器侧/设计器经 `execute_editor_script` + `validate_script`)。
> **规范**:所有 `.gd` 改动后 `validate_script`;纯数据驱动无硬编码;提交中文;分支 `feat/element-resistance`(已建)。spec`docs_dev/specs/2026-07-24-element-resistance-design.md`。DamageTypePHYSICAL=0/FIRE=1/ICE=2/LIGHTNING=3/POISON=4/ARCANE=5。
---
### Task 1: 核心 —— EnemyManager 抗性加载 + 命中查表 + enemies.json 种子
**Files:**
- Modify: `data/enemies.json`(给 "1" Fast 加示例抗性/弱点)
- Modify: `scripts/autoloads/enemy_manager.gd`(成员区 :40-43、`_load_json_archetypes` :56-63、`apply_damage_from_context` :253-258
- [ ] **Step 1: enemies.json 加种子抗性**
`data/enemies.json` 中,给 `"1"`(快速 Fast)项加 `resistances`(抗火、弱冰),用于实测。改为:
```json
"1": { "name": "快速 Fast", "speed": 140, "armor": 0, "size": 11, "color": [0.95, 0.72, 0.20], "resistances": { "fire": 0.5, "ice": -0.5 } },
```
(其余型不加,保持缺失=0 的回退路径可测。注意 JSON 逗号。)
- [ ] **Step 2: EnemyManager 增成员 + 元素映射常量**
`enemy_manager.gd:43``var _RENDER_COLOR...` 之后)新增:
```gdscript
var _RESIST: Dictionary = {} # type → PackedFloat32Array(6)index=damage_type[0]物理恒0
const _RESIST_ELEMENTS := {"fire": 1, "ice": 2, "lightning": 3, "poison": 4, "arcane": 5}
```
- [ ] **Step 3: `_load_json_archetypes` 加载抗性**
`enemy_manager.gd``_load_json_archetypes()` 每型循环内(`_RENDER_COLOR[t] = ...` 之后,`:63` 处)追加:
```gdscript
var rf := PackedFloat32Array([0, 0, 0, 0, 0, 0])
var res_d: Dictionary = d.get("resistances", {})
for ename in _RESIST_ELEMENTS:
if res_d.has(ename):
rf[_RESIST_ELEMENTS[ename]] = clampf(float(res_d[ename]), -0.9, 0.9)
_RESIST[t] = rf
```
- [ ] **Step 4: 增查表辅助**
`apply_damage_from_context` 之前(或就近)新增:
```gdscript
## 元素抗性查表:越界/缺失回退 0(物理 index 0 恒 0)
func _resist_for(etype: int, dtype: int) -> float:
var rf: PackedFloat32Array = _RESIST.get(etype, PackedFloat32Array())
if dtype < 0 or dtype >= rf.size():
return 0.0
return rf[dtype]
```
- [ ] **Step 5: 改写 `apply_damage_from_context` 传入抗性**
`enemy_manager.gd:253-258` 的整个函数替换为:
```gdscript
func apply_damage_from_context(entity_id: int, ctx_id: int) -> bool:
var ctx: DamageContext = DamageContextPool.get_context(ctx_id)
if ctx == null:
return false
var slot: int = _entity_index_map.get(entity_id, -1)
var res: float = 0.0
if slot >= 0:
var etype: int = int(_data[slot * ENEMY_STRIDE + 6])
res = _resist_for(etype, ctx.damage_type)
var final_dmg: float = ctx.calc_damage(0.0, res)
return apply_damage(entity_id, final_dmg, ctx.owner_id, true)
```
(护甲仍由 `apply_damage``_ARMOR` 处理,勿动。)
- [ ] **Step 6: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/enemy_manager.gd`
Expected: 无错误。
- [ ] **Step 7: 校验 JSON + 加载(编辑器脚本)**
Run: godot-mcp-pro `execute_editor_script`
```gdscript
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/enemies.json"))
var ok := d is Dictionary and d.has("1") and d["1"].has("resistances")
ok = ok and abs(float(d["1"]["resistances"].get("fire", 0)) - 0.5) < 0.001
ok = ok and abs(float(d["1"]["resistances"].get("ice", 0)) + 0.5) < 0.001
print("FAILS=", 0 if ok else 1)
```
Expected: `FAILS=0`
- [ ] **Step 8: 提交**
```bash
git add data/enemies.json scripts/autoloads/enemy_manager.gd
git commit -m "feat(resist): EnemyManager 元素抗性加载+命中查表;enemies.json Fast 种子抗火弱冰"
```
heredocbody 末尾加 `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>`。)
---
### Task 2: 设计器 —— enemy_tab 抗性网格 + _save 写入
**Files:**
- Modify: `addons/game_designer/enemy_tab.gd`(成员 :9-11、`_ready` :24-40、`_save` :46-60
- [ ] **Step 1: 增元素常量 + 行结构说明**
`enemy_tab.gd:7``const NAMES...` 之后)新增:
```gdscript
const ELEM_KEYS = ["fire", "ice", "lightning", "poison", "arcane"]
const ELEM_LABELS = ["火 Fire", "冰 Ice", "雷 Lightning", "毒 Poison", "奥 Arcane"]
```
- [ ] **Step 2: `_ready` 主表后加抗性网格**
`enemy_tab.gd``_ready()` 内、`add_child(HSeparator.new())`(现 :37,主表与保存按钮之间)**之后、保存按钮之前**插入:
```gdscript
add_child(UI.cell_label("元素抗性 Resistances-0.9~0.9,负=弱点加伤)"))
var rgrid := GridContainer.new()
rgrid.columns = ELEM_KEYS.size() + 1
add_child(rgrid)
var rhead := Label.new(); rhead.text = "原型"; rhead.modulate = Color(0.7, 0.8, 1.0)
rgrid.add_child(rhead)
for el in ELEM_LABELS:
var el_l := Label.new(); el_l.text = el; el_l.modulate = Color(0.7, 0.8, 1.0)
rgrid.add_child(el_l)
for i in 6:
var d2: Dictionary = _data.get(str(i), {})
var res_d: Dictionary = d2.get("resistances", {})
rgrid.add_child(UI.cell_label(NAMES[i]))
var res_spins := {}
for ei in ELEM_KEYS.size():
var ename: String = ELEM_KEYS[ei]
var sp2 := UI.spin(-0.9, 0.9, 0.05, float(res_d.get(ename, 0.0)))
rgrid.add_child(sp2)
res_spins[ename] = sp2
_rows[i]["resist"] = res_spins
```
(注意:此块必须在 6 行主表 `for i in 6` 之后运行,`_rows` 已填满 6 项——上面的 `_rows[i]["resist"] = ...` 给已存在的行字典追加键。)
- [ ] **Step 3: 改写 `_save` 写入 resistances**
`enemy_tab.gd:46-60``_save` 整体替换为:
```gdscript
func _save() -> void:
var out: Dictionary = {}
for i in 6:
var r: Dictionary = _rows[i]
var entry := {
"name": NAMES[i],
"speed": r["speed"].value,
"armor": r["armor"].value,
"size": r["size"].value,
"color": [r["color"].color.r, r["color"].color.g, r["color"].color.b],
}
var rd := {}
for ename in ELEM_KEYS:
var v: float = r["resist"][ename].value
if absf(v) > 0.0001:
rd[ename] = v
if not rd.is_empty():
entry["resistances"] = rd
out[str(i)] = entry
if UI.save_json(PATH, out):
UI.set_status(_status, "💾 已保存(重启游戏 F5 生效)")
else:
UI.set_status(_status, "✗ 保存失败", true)
```
- [ ] **Step 4: 校验语法**
Run: godot-mcp-pro `validate_script` on `res://addons/game_designer/enemy_tab.gd`
Expected: 无错误。
- [ ] **Step 5: 设计器读写往返实测**
> ⚠️ dock 内驻留旧类需 `EditorInterface.restart_editor(true)` 清缓存后新代码才生效(CLAUDE.md);若下方断言用旧类,先重启编辑器再跑。fresh `load().new()` 通常可避开。
Run: godot-mcp-pro `execute_editor_script`
```gdscript
var Tab = load("res://addons/game_designer/enemy_tab.gd")
var t = Tab.new(); add_child(t)
var fails := 0
# 读:Fast(1) fire 应=0.5, ice=-0.5
if abs(t._rows[1]["resist"]["fire"].value - 0.5) > 0.001: fails += 1
if abs(t._rows[1]["resist"]["ice"].value + 0.5) > 0.001: fails += 1
# 写往返:改 Basic(0) poison=0.3Fast(1) fire→0,重存重读
t._rows[0]["resist"]["poison"].value = 0.3
t._rows[1]["resist"]["fire"].value = 0.0
t._save()
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/enemies.json"))
if abs(float(d["0"].get("resistances", {}).get("poison", -9)) - 0.3) > 0.001: fails += 1 # 写入非零
if d["1"].get("resistances", {}).has("fire"): fails += 1 # 归零应省略键
if not d["1"].get("resistances", {}).has("ice"): fails += 1 # ice=-0.5 仍在
t.queue_free()
print("FAILS=", fails)
```
Expected: `FAILS=0`。测试后用 `git checkout -- data/enemies.json` 还原到 Task 1 种子状态(`_save` 会 pretty-print 重排文件)。
- [ ] **Step 6: 提交**
```bash
git checkout -- data/enemies.json # 还原往返测试对文件的改写/重排
git add addons/game_designer/enemy_tab.gd
git commit -m "feat(resist): 设计器敌人页增元素抗性网格(6×5) + _save 写 resistances(全0省略)"
```
---
### Task 3: 验收 —— 运行时集成实测(MCP)+ 收尾
**Files:** 无(运行时断言)+ 收尾文档
- [ ] **Step 1: 启动战斗场景**
Run: godot-mcp-pro `play_scene``res://scenes/main/combat_s2.tscn`。确认运行、无 `get_editor_errors`
- [ ] **Step 2: 抗性/弱点/物理对照(验收①②③④)**
> 方法:直接生成敌人 + 构造 DamageContext 命中,绕过子弹几何,精确对照。Fast(type=1) 抗火0.5/弱冰-0.5Basic(type=0) 无抗性。
> `spawn_enemy(pos, hp, enemy_type)` 签名已确认(`enemy_manager.gd:121`)。用 lambda 辅助(`execute_game_script` 体内不能定义具名 func,须用 Callable)。
Run: godot-mcp-pro `execute_game_script`
```gdscript
var fails := 0
# lambda:对 (enemy_type, damage_type) 生成敌人+施加 base=100 伤害,返回实际掉血
var probe := func(etype, dtype):
var eid = EnemyManager.spawn_enemy(Vector2(300, 0), 1000.0, etype)
var before = EnemyManager.get_hp_percent(eid) * 1000.0
var cid = DamageContextPool.acquire(100.0, 1.0, dtype, -1, false, 0.0)
EnemyManager.apply_damage_from_context(eid, cid)
DamageContextPool.release(cid)
return before - EnemyManager.get_hp_percent(eid) * 1000.0
var f1 = probe.call(1, 1) # Fast 抗火0.5 → ≈50
if abs(f1 - 50.0) > 1.0: fails += 1
var f2 = probe.call(1, 2) # Fast 弱冰-0.5 → ≈150
if abs(f2 - 150.0) > 1.0: fails += 1
var f3 = probe.call(1, 0) # Fast 物理无抗 → ≈100
if abs(f3 - 100.0) > 1.0: fails += 1
var b1 = probe.call(0, 1) # Basic 无 resistances 火 → 回退0 ≈100
if abs(b1 - 100.0) > 1.0: fails += 1
var e1 = probe.call(0, 99) # 越界 damage_type 不崩 → 回退0 ≈100
if abs(e1 - 100.0) > 1.0: fails += 1
print("FAILS=", fails, " fire_resist=", f1, " ice_weak=", f2, " phys=", f3, " basic_fire=", b1, " oob=", e1)
```
Expected: `FAILS=0`fire_resist≈50 / ice_weak≈150 / phys≈100 / basic_fire≈100 / oob≈100。
> 注:apply_damage 内 combo_bonus 默认 1.0(无 COMBO_MARK 层),armor 按型(Fast=0/Basic=0)故上述期望值成立。若某敌被一次伤害不足以在同一 slot 留存(如秒杀),大血量 1000 已规避。
- [ ] **Step 3: 护甲叠加对照(验收③)**
Run: godot-mcp-pro `execute_game_script`(复用上面 `_spawn`/`_hit` 或重新定义):
```gdscript
# Armored(2) armor=5,无 resistances:火(1) base100 → max(1, 100×1×1 5)=95
var fails := 0
var cid = DamageContextPool.acquire(100.0, 1.0, 1, -1, false, 0.0)
var eid = EnemyManager.spawn_enemy(Vector2(300, 0), 1000.0, 2) # (pos, hp, enemy_type=Armored)
var before = EnemyManager.get_hp_percent(eid) * 1000.0
EnemyManager.apply_damage_from_context(eid, cid)
DamageContextPool.release(cid)
var drop = before - EnemyManager.get_hp_percent(eid) * 1000.0
if abs(drop - 95.0) > 1.0: fails += 1
print("FAILS=", fails, " armored_drop=", drop) # 期望 ≈95(护甲减免生效、与抗性正交)
```
Expected: `FAILS=0`armored_drop≈95。然后 `stop_scene`
- [ ] **Step 4: 更新路线图 + memory + 提交**
- `docs_dev/plans/2026-07-23-missing-features-roadmap.md`:E1「现状」抗性行标记完成、切分①勾除。
- memory `slice-progress-vs-plan.md`:记 E1-① 元素抗性完成(分支/commit+ MEMORY.md 索引。
```bash
git add docs_dev/plans/2026-07-23-missing-features-roadmap.md
git commit -m "docs(roadmap): E1-① 元素抗性完成,勾除现状项"
```
---
## 验收标准回溯(spec §4
| # | 验收标准 | 覆盖任务 |
| :- | :-- | :-- |
| 1 | 抗火减半 / 物理不变 | Task 3 Step 2 (f1, f3) |
| 2 | 负抗性放大 1.5× | Task 3 Step 2 (f2) |
| 3 | 抗性与护甲叠加正确 | Task 3 Step 3 |
| 4 | 缺失/未列元素回退 0 | Task 3 Step 2 (b1, oob) |
| 5 | 设计器往返 FAILS=0(负值/全0省略) | Task 2 Step 5 |
| 6 | validate 通过 / 越界不崩 | 各 validate 步 + Task 3 Step 2 (oob) |