docs(plan): E6-a 玩家无敌帧逐任务实现计划(5 任务 · TDD-MCP 实测)
This commit is contained in:
@@ -0,0 +1,383 @@
|
|||||||
|
# 玩家无敌帧(i-frames)Implementation 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:** 玩家受击后获得数据驱动的短暂无敌窗口,杜绝多弹同帧连扣血,并复活当前失效的 hurt 受击反馈。
|
||||||
|
|
||||||
|
**Architecture:** 单点集中在 `PlayerStats.take_damage()` 用 `Time.get_ticks_msec()` 时间戳门控(无每帧 tick);时长入 `balance.json` → `SettingsManager` 访问器;`combat_s2._process` 每帧读 `is_invulnerable()` 驱动玩家节点闪烁;`take_damage` 复活 `PLAYER_DAMAGED` emit(连带 `audio_manager` 的 hurt 订阅)。
|
||||||
|
|
||||||
|
**Tech Stack:** Godot 4.7.1 Mono / GDScript / 纯 JSON 数据驱动 / godot-mcp-pro 运行时实测(本工程无 pytest;autoload 逻辑经 `execute_game_script` 在运行中的战斗场景断言,编辑器侧数据/设计器经 `execute_editor_script` + `validate_script`)。
|
||||||
|
|
||||||
|
> **规范**:所有 `.gd` 改动后 `validate_script`;纯数据驱动无硬编码时长副本;提交信息中文;分支 `feat/player-iframes`(已建)。spec:`docs_dev/specs/2026-07-24-player-iframes-design.md`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 数据层 —— balance.json 键 + SettingsManager 访问器
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `data/balance.json`
|
||||||
|
- Modify: `scripts/autoloads/settings_manager.gd`(成员变量区 :34-38、`_load_balance` 尾 :91-93、访问器区 :66-70)
|
||||||
|
|
||||||
|
- [ ] **Step 1: balance.json 增顶层键**
|
||||||
|
|
||||||
|
在 `data/balance.json` 的 `infinite_spells` 之后新增一行(同级顶层键):
|
||||||
|
|
||||||
|
```json
|
||||||
|
"infinite_spells": { "hp_per_shot": 1.0, "hp_cost_cap_ratio": 0.15 },
|
||||||
|
"player_iframe_sec": 0.5
|
||||||
|
```
|
||||||
|
|
||||||
|
(注意给 `infinite_spells` 行补逗号。最终顶层键顺序:difficulty_mults, boss_hp, aether_thresholds, mana_heat, infinite_spells, player_iframe_sec。)
|
||||||
|
|
||||||
|
- [ ] **Step 2: SettingsManager 增成员变量**
|
||||||
|
|
||||||
|
在 `settings_manager.gd:38`(`var _INFINITE...` 之后)新增:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var _IFRAME_SEC: float = 0.5 # 玩家受击无敌窗口(秒);balance.json player_iframe_sec
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: `_load_balance` 读取该键**
|
||||||
|
|
||||||
|
在 `settings_manager.gd` 的 `_load_balance()` 末尾(现 :91-93 `infinite_spells` 块之后)追加:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
if data.has("player_iframe_sec"):
|
||||||
|
_IFRAME_SEC = float(data["player_iframe_sec"])
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 增访问器**
|
||||||
|
|
||||||
|
在 `settings_manager.gd:70`(`hp_cost_cap_ratio()` 之后)新增:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func player_iframe_sec() -> float: return _IFRAME_SEC
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 校验语法**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/settings_manager.gd`
|
||||||
|
Expected: 无错误。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 校验 JSON + 访问器(编辑器脚本)**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `execute_editor_script`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/balance.json"))
|
||||||
|
var ok := d is Dictionary and d.has("player_iframe_sec") and abs(float(d["player_iframe_sec"]) - 0.5) < 0.0001
|
||||||
|
print("FAILS=", 0 if ok else 1, " val=", d.get("player_iframe_sec"))
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILS=0 val=0.5`
|
||||||
|
|
||||||
|
- [ ] **Step 7: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add data/balance.json scripts/autoloads/settings_manager.gd
|
||||||
|
git commit -m "feat(iframe): balance.json 增 player_iframe_sec 键 + SettingsManager 访问器(默认0.5s)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 核心 —— PlayerStats 无敌帧门控 + 复活 PLAYER_DAMAGED + 移除死订阅
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/autoloads/player_stats.gd`(成员区 :22-23、`_ready` :32-34、`take_damage` :65-69、`_on_player_damaged` :116-117、`reset_for_run` :119-126)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 增无敌窗口成员变量**
|
||||||
|
|
||||||
|
在 `player_stats.gd:23`(`var resistance...` 之后)新增:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var _invuln_until_msec: int = 0 # < now 表示可受击;受击后设为 now + iframe 窗口
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 改写 `take_damage` 加门控 + emit**
|
||||||
|
|
||||||
|
将 `player_stats.gd:65-69` 的 `take_damage` 整体替换为:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func take_damage(amount: float) -> void:
|
||||||
|
var now: int = Time.get_ticks_msec()
|
||||||
|
if now < _invuln_until_msec:
|
||||||
|
return # 无敌窗口内:忽略外部伤害(多弹同帧只扣一次)
|
||||||
|
var applied: float = amount * SettingsManager.player_dmg_taken_mult() # 难度减伤(初学者×0.7)
|
||||||
|
hp = max(0.0, hp - applied)
|
||||||
|
_invuln_until_msec = now + int(SettingsManager.player_iframe_sec() * 1000.0)
|
||||||
|
EventBus.emit(EventID.PLAYER_DAMAGED, {"damage": applied}) # 复活 hurt 音效 / HUD 钩子
|
||||||
|
stats_changed.emit()
|
||||||
|
if hp <= 0.0:
|
||||||
|
EventBus.emit(EventID.PLAYER_DIED, {})
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 增 `is_invulnerable()` 查询**
|
||||||
|
|
||||||
|
在改写后的 `take_damage` 之后新增:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func is_invulnerable() -> bool:
|
||||||
|
return Time.get_ticks_msec() < _invuln_until_msec
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 移除死订阅(避免 emit 自递归)**
|
||||||
|
|
||||||
|
删除 `player_stats.gd:33` 的这一行:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
EventBus.subscribe(EventID.PLAYER_DAMAGED, _on_player_damaged)
|
||||||
|
```
|
||||||
|
|
||||||
|
并删除 `player_stats.gd:116-117` 的整个 `_on_player_damaged` 函数:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _on_player_damaged(payload: Dictionary) -> void:
|
||||||
|
take_damage(float(payload.get("damage", 0.0)))
|
||||||
|
```
|
||||||
|
|
||||||
|
(`_ready` 保留 `ENEMY_KILLED` 订阅不动。)
|
||||||
|
|
||||||
|
- [ ] **Step 5: `reset_for_run` 重置无敌窗口**
|
||||||
|
|
||||||
|
在 `player_stats.gd` 的 `reset_for_run()` 内(`stats_changed.emit()` 之前)新增一行:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
_invuln_until_msec = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: 校验语法**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `validate_script` on `res://scripts/autoloads/player_stats.gd`
|
||||||
|
Expected: 无错误(确认 `_on_player_damaged` 已无引用残留)。
|
||||||
|
|
||||||
|
- [ ] **Step 7: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/autoloads/player_stats.gd
|
||||||
|
git commit -m "feat(iframe): PlayerStats.take_damage 时间戳门控无敌窗口 + 复活 PLAYER_DAMAGED emit;移除死订阅"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: 视觉反馈 —— combat_s2 无敌期玩家闪烁
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scenes/main/combat_s2.gd`(`_process` 尾 :99-104、`_player_node` 成员)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 确认 `_player_node` 成员存在**
|
||||||
|
|
||||||
|
Run: `grep -n "_player_node" scenes/main/combat_s2.gd`
|
||||||
|
Expected: 见成员声明 + `_setup_player` 赋值(:112)。若无成员声明则在类顶部 var 区补 `var _player_node: Node2D = null`(`_setup_player` 已用它,通常已声明)。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 `_process` 末尾追加闪烁驱动**
|
||||||
|
|
||||||
|
在 `combat_s2.gd` 的 `_process(delta)` 末尾(现 DPS 轮询块 :100-104 之后)追加:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
# 无敌帧闪烁反馈:8Hz 方波(相位来自系统时钟,避免累加状态)
|
||||||
|
if _player_node:
|
||||||
|
if PlayerStats.is_invulnerable():
|
||||||
|
var on: bool = (Time.get_ticks_msec() / 62) % 2 == 0 # ≈8Hz(62ms 半周期)
|
||||||
|
_player_node.modulate.a = 1.0 if on else 0.35
|
||||||
|
elif _player_node.modulate.a != 1.0:
|
||||||
|
_player_node.modulate.a = 1.0 # 窗口结束确保恢复不透明
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 校验语法**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `validate_script` on `res://scenes/main/combat_s2.gd`
|
||||||
|
Expected: 无错误。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scenes/main/combat_s2.gd
|
||||||
|
git commit -m "feat(iframe): 无敌期间玩家节点 8Hz 方波闪烁反馈"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 设计器 —— 平衡页 player_iframe_sec 输入栏(含防丢键修复)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `addons/game_designer/balance_tab.gd`(成员 :11-16、`_ready` 尾 :55-57、`_save` :63-77)
|
||||||
|
|
||||||
|
> **说明**:现 `_save()` 用全新 dict 输出,会**丢弃** `mana_heat`/`infinite_spells` 键(违反 CLAUDE.md 设计器「防丢键」约定)。本任务顺带修复:`_save` 以 `_data` 副本为基底合并已知键,既保留未知键又加入新键。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 增成员变量**
|
||||||
|
|
||||||
|
在 `balance_tab.gd:16`(`var _status: Label` 之前)新增:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var _iframe: SpinBox
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: `_ready` 增输入栏**
|
||||||
|
|
||||||
|
在 `balance_tab.gd` 的 `_ready()` 内、保存按钮(`add_child(UI.btn("💾 保存 balance.json", _save))`,现 :56)**之前**插入:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
add_child(HSeparator.new())
|
||||||
|
var hf := HBoxContainer.new(); add_child(hf)
|
||||||
|
hf.add_child(UI.cell_label("无敌帧时长(秒) player_iframe_sec"))
|
||||||
|
_iframe = UI.spin(0.0, 2.0, 0.05, float(_data.get("player_iframe_sec", 0.5)))
|
||||||
|
hf.add_child(_iframe)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 改写 `_save` 为合并式(防丢键 + 写入新键)**
|
||||||
|
|
||||||
|
将 `balance_tab.gd:63-77` 的 `_save` 整体替换为:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
func _save() -> void:
|
||||||
|
var mults: Dictionary = {}
|
||||||
|
for key in MULT_KEYS:
|
||||||
|
var spins: Array = _mult_spins[key]
|
||||||
|
mults[key] = [spins[0].value, spins[1].value, spins[2].value]
|
||||||
|
var out: Dictionary = _data.duplicate(true) # 基底保留未知键(mana_heat/infinite_spells 等)
|
||||||
|
out["difficulty_mults"] = mults
|
||||||
|
out["boss_hp"] = {"4": _miniboss.value, "5": _boss.value}
|
||||||
|
out["aether_thresholds"] = _aether.get_values()
|
||||||
|
out["player_iframe_sec"] = _iframe.value
|
||||||
|
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/balance_tab.gd`
|
||||||
|
Expected: 无错误。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 设计器读写往返实测**
|
||||||
|
|
||||||
|
> ⚠️ dock 内驻留旧类需 `EditorInterface.restart_editor(true)` 清缓存后新代码才生效(见 CLAUDE.md)。若断言用旧类失败,先重启编辑器再跑。
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `execute_editor_script`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
var Tab = load("res://addons/game_designer/balance_tab.gd")
|
||||||
|
var t = Tab.new(); add_child(t) # 触发 _ready 从 balance.json 装载
|
||||||
|
await get_tree().process_frame
|
||||||
|
var fails := 0
|
||||||
|
# 读:SpinBox 应等于文件值 0.5
|
||||||
|
if abs(t._iframe.value - 0.5) > 0.0001: fails += 1
|
||||||
|
# 写往返:改值→_save→重读文件应一致,且未知键 mana_heat/infinite_spells 保留
|
||||||
|
t._iframe.value = 0.65
|
||||||
|
t._save()
|
||||||
|
var d = JSON.parse_string(FileAccess.get_file_as_string("res://data/balance.json"))
|
||||||
|
if abs(float(d.get("player_iframe_sec", -1)) - 0.65) > 0.0001: fails += 1
|
||||||
|
if not (d.has("mana_heat") and d.has("infinite_spells")): fails += 1 # 防丢键
|
||||||
|
# 复原为 0.5
|
||||||
|
t._iframe.value = 0.5; t._save()
|
||||||
|
t.queue_free()
|
||||||
|
print("FAILS=", fails)
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILS=0`(并确认文件 `player_iframe_sec` 复原为 0.5、mana_heat/infinite_spells 仍在)。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 提交**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add addons/game_designer/balance_tab.gd
|
||||||
|
git commit -m "feat(iframe): 设计器平衡页增 player_iframe_sec 栏;_save 改合并式修复丢键"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 验收 —— 运行时集成实测(MCP)
|
||||||
|
|
||||||
|
**Files:** 无(纯运行时断言)
|
||||||
|
|
||||||
|
> 目标:在真实运行的战斗场景验证 spec §4 全部验收标准。
|
||||||
|
|
||||||
|
- [ ] **Step 1: 启动战斗场景**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `play_scene` → `res://scenes/main/combat_s2.tscn`(若该场景需先建 run 状态,改用工程默认主场景进入战斗;确认玩家已生成、`PlayerManager.get_position()` 有效)。
|
||||||
|
Expected: 场景运行,无编辑器错误。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 多弹同帧只扣一次血(验收①②)**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `execute_game_script`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
PlayerStats._invuln_until_msec = 0
|
||||||
|
PlayerStats.hp = 100.0
|
||||||
|
var p: Vector2 = PlayerManager.get_position()
|
||||||
|
# 同一帧在玩家身上生成 5 颗敌弹(各 10 伤)
|
||||||
|
for k in 5:
|
||||||
|
EnemyBulletManager.spawn_bullet(p, Vector2.ZERO, 5.0, 4.0, 10.0)
|
||||||
|
await get_tree().physics_frame # 让 EnemyBulletManager._physics_process 处理命中
|
||||||
|
await get_tree().physics_frame
|
||||||
|
var dmg := 100.0 - PlayerStats.hp
|
||||||
|
var one_hit := 10.0 * SettingsManager.player_dmg_taken_mult()
|
||||||
|
var fails := 0
|
||||||
|
if abs(dmg - one_hit) > 0.01: fails += 1 # 只扣一次
|
||||||
|
if not PlayerStats.is_invulnerable(): fails += 1 # 受击后处于无敌
|
||||||
|
print("FAILS=", fails, " dmg=", dmg, " expected_one=", one_hit)
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILS=0`,`dmg` ≈ 单次伤害(标准难度 10.0)。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 窗口到期后可再次受击(验收②)**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `execute_game_script`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
PlayerStats._invuln_until_msec = 0
|
||||||
|
PlayerStats.hp = 100.0
|
||||||
|
PlayerStats.take_damage(10.0)
|
||||||
|
var after_first := PlayerStats.hp
|
||||||
|
await create_tween().tween_interval(SettingsManager.player_iframe_sec() + 0.1).finished
|
||||||
|
PlayerStats.take_damage(10.0)
|
||||||
|
var fails := 0
|
||||||
|
if not (PlayerStats.hp < after_first - 0.01): fails += 1 # 窗口后二次生效
|
||||||
|
print("FAILS=", fails, " hp=", PlayerStats.hp)
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILS=0`,HP 两次均下降。
|
||||||
|
|
||||||
|
- [ ] **Step 4: spend_hp_cost 不受无敌影响(验收④)**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `execute_game_script`:
|
||||||
|
|
||||||
|
```gdscript
|
||||||
|
PlayerStats.hp = 100.0
|
||||||
|
PlayerStats.take_damage(10.0) # 进入无敌
|
||||||
|
var mid := PlayerStats.hp
|
||||||
|
PlayerStats.spend_hp_cost(5.0) # 自伤:应无视无敌,正常扣
|
||||||
|
var fails := 0
|
||||||
|
if abs((mid - 5.0) - PlayerStats.hp) > 0.01: fails += 1
|
||||||
|
print("FAILS=", fails, " hp=", PlayerStats.hp, " expected=", mid - 5.0)
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `FAILS=0`,卖血正常扣 5。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 截图确认闪烁 + 收尾**
|
||||||
|
|
||||||
|
Run: godot-mcp-pro `get_game_screenshot`(受击瞬间附近,观察玩家节点闪烁;非强制像素断言,目视确认)。然后 `stop_scene`。
|
||||||
|
Expected: 玩家标记在无敌期呈半透明闪烁。
|
||||||
|
|
||||||
|
- [ ] **Step 6: 更新路线图 + memory + 提交**
|
||||||
|
|
||||||
|
- 在 `docs_dev/plans/2026-07-23-missing-features-roadmap.md` 的 E6「现状」勾除「玩家无敌帧」项。
|
||||||
|
- 更新 memory `slice-progress-vs-plan.md`:记 E6-a i-frames 完成(分支/commit)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs_dev/plans/2026-07-23-missing-features-roadmap.md
|
||||||
|
git commit -m "docs(roadmap): E6-a 玩家无敌帧完成,勾除现状项"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 验收标准回溯(spec §4)
|
||||||
|
|
||||||
|
| # | 验收标准 | 覆盖任务 |
|
||||||
|
| :- | :-- | :-- |
|
||||||
|
| 1 | 多弹同帧只扣一次血 | Task 5 Step 2 |
|
||||||
|
| 2 | 窗口内免疫 / 窗口后可再受击 | Task 5 Step 2,3 |
|
||||||
|
| 3 | 无敌期闪烁 + hurt 音效 | Task 3 + Task 2(emit)+ Task 5 Step 5 |
|
||||||
|
| 4 | spend_hp_cost 不受影响 | Task 5 Step 4 |
|
||||||
|
| 5 | balance.json 改值生效 + 设计器往返 FAILS=0 | Task 1 Step 6 + Task 4 Step 5 |
|
||||||
|
| 6 | validate_script 通过 / 无硬编码时长副本 | 各 Task validate 步 |
|
||||||
Reference in New Issue
Block a user