Files
spellforge/docs_dev/plans/2026-07-24-player-iframes.md
T

384 lines
14 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.
# 玩家无敌帧(i-framesImplementation 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 运行时实测(本工程无 pytestautoload 逻辑经 `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 # ≈8Hz62ms 半周期)
_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 2emit+ 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 步 |