docs(spec): E6-a 玩家无敌帧设计——take_damage 单点门控 + 复活 hurt 反馈 + 数据驱动时长

缺失功能路线图 E6-a 先行小项。受击后 0.5s 无敌窗口(时间戳门控,
杜绝多弹同帧连扣血);顺带复活当前死代码的 PLAYER_DAMAGED emit 与
hurt 音效;spend_hp_cost 自伤不受影响;时长入 balance.json + 设计器。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-24 09:08:04 +08:00
co-authored by Claude Opus 4.8
parent 0fb2cf6646
commit 383791db14
@@ -0,0 +1,81 @@
# 玩家无敌帧(i-frames)设计
> **日期**2026-07-24
> **Epic**E6-a(缺失功能路线图 `docs_dev/plans/2026-07-23-missing-features-roadmap.md`
> **优先级**:P1 先行小项 · 工作量 S
> **目标**:玩家受击后获得短暂无敌窗口,杜绝多弹同帧连扣血,提升 Boss 弹幕公平性;顺带复活当前失效的受击反馈(hurt 音效 / HUD)。
---
## 1. 背景与现状(代码复核 2026-07-24
- 玩家外部伤害**唯一入口**为 `PlayerStats.take_damage(amount)``player_stats.gd:65`)。调用方 3 处:
- `enemy_bullet_manager.gd:33` —— 敌弹命中。
- `boss_manager.gd:212` —— Boss 冲刺接触(该 action 自带 `hit_cd` 0.4s)。
- `boss_manager.gd:235` —— Boss 近战 windup 命中。
- **无任何无敌窗口**:多颗敌弹同一 `_physics_process` 帧命中会连续多次扣血,弹幕海里不公平。
- **`PLAYER_DAMAGED`(=5) 事件当前无人 emit**grep 零匹配):
- `audio_manager.gd:36` 订阅它播 `hurt` 音效 → **死代码,从不触发**
- `player_stats.gd:33` 自订阅 `PLAYER_DAMAGED → _on_player_damaged → take_damage`**死代码**(无人 emit)。
- 自伤入口 `spend_hp_cost``player_stats.gd:108`infinite_spells 卖血)与外部伤害**语义独立**,不应受无敌帧影响。
- 玩家可视节点为 `combat_s2.gd:_setup_player()` 创建的 `Node2D`(含青色菱形 `Polygon2D` + 光晕),存于 `PlayerManager._player_node``modulate` 可用于闪烁。
## 2. 设计
**单点集中在 `PlayerStats.take_damage()`** —— 3 处调用方无需改动逻辑(仅确认走此入口)。
### 2.1 无敌窗口机制
- `PlayerStats` 新增 `var _invuln_until_msec: int = 0`(时间戳,`Time.get_ticks_msec()`,与 `player_manager` dash 边沿同法,**无需每帧 tick 驱动**)。
- `take_damage(amount)` 改为:
1. `var now := Time.get_ticks_msec()`
2.`now < _invuln_until_msec`**直接 return**(无敌中,忽略本次外部伤害)。
3. 否则:扣血(保留原难度减伤 `player_dmg_taken_mult()`);设 `_invuln_until_msec = now + int(SettingsManager.player_iframe_sec() * 1000.0)``EventBus.emit(PLAYER_DAMAGED, {"damage": applied})`(复活 hurt 音效 / 未来 HUD 钩子)。
4. `stats_changed.emit()``hp<=0` 时发 `PLAYER_DIED`(同现状)。
- 新增查询 `func is_invulnerable() -> bool: return Time.get_ticks_msec() < _invuln_until_msec`(供 HUD 闪烁读)。
- **移除** `player_stats.gd:33``EventBus.subscribe(PLAYER_DAMAGED, _on_player_damaged)``_on_player_damaged` 函数 —— 否则 `take_damage` emit `PLAYER_DAMAGED` 会自递归;该订阅本就是死代码。
- **`spend_hp_cost` 保持不变** —— 不查 / 不设无敌窗口(自伤代价独立)。
- **`reset_for_run()` 重置** `_invuln_until_msec = 0`
### 2.2 覆盖来源
三处外部伤害全部经 `take_damage` → 自动受无敌帧保护,**无需逐点改**。Boss 冲刺原有 `hit_cd` 保留(全局 i-frame 主导,二者叠加无害)。
### 2.3 数据驱动
- `data/balance.json` 顶层新增 `"player_iframe_sec": 0.5`
- `SettingsManager` 新增访问器 `func player_iframe_sec() -> float`(读 `_balance.get("player_iframe_sec", 0.5)`,缺失回退 0.5 并可 push_warning,与既有 `_mult` / infinite 访问器同风格)。
- 游戏设计器「平衡 Balance」页新增一栏 `SpinBox`0.0~2.0step 0.05),复用 `designer_ui.gd``UI.spin`,中英标签「无敌帧时长(秒) player_iframe_sec」,读写往返一致。
### 2.4 视觉反馈(HUD 闪烁)
- `combat_s2._process(delta)` 每帧末尾:读 `PlayerStats.is_invulnerable()`
- 无敌中:`_player_node.modulate.a` 按 8Hz 方波在 `1.0 / 0.35` 间切换(用 `Time.get_ticks_msec()` 求相位,避免累加状态)。
- 非无敌:`_player_node.modulate.a = 1.0`(确保恢复)。
- hurt 音效经 §2.1 的 `PLAYER_DAMAGED` emit 自动复活(`audio_manager.gd:36` 现有订阅)。
## 3. 影响文件
| 文件 | 改动 |
| :-- | :-- |
| `scripts/autoloads/player_stats.gd` | i-frame 时间戳 + `take_damage` 门控 + emit + `is_invulnerable()`;移除死订阅 |
| `scripts/autoloads/settings_manager.gd` | `player_iframe_sec()` 访问器 |
| `data/balance.json` | 新增 `player_iframe_sec: 0.5` |
| `scenes/main/combat_s2.gd` | `_process` 无敌期玩家节点闪烁 |
| `addons/game_designer/*`(平衡页) | 新增 `player_iframe_sec` SpinBox + 读写 |
## 4. 验收标准
1. 多颗敌弹同帧命中玩家 → HP **只扣一次**MCP:批量 `spawn_bullet` 于玩家位置,断言 HP 降幅 = 单次)。
2. 受击后 `player_iframe_sec` 秒内任何外部伤害被忽略;窗口结束后可再次受击。
3. 无敌期间玩家节点闪烁;`hurt` 音效在实际受击时播放。
4. `spend_hp_cost`(卖血)**不受** i-frame 影响,仍正常扣血。
5. balance.json 改 `player_iframe_sec` → 窗口时长实测随之变化;设计器该栏读写往返 `FAILS=0`
6. 全程 GDScript `validate_script` 通过;纯数据驱动,无硬编码时长副本。
## 5. 非目标(YAGNI
- 不做「击退 / 受击硬直」。
- 不做「按伤害来源区分不同无敌时长」(统一一个窗口)。
- 不改 Boss 冲刺 `hit_cd`(保留,无害)。
- 不做无敌期间的碰撞体尺寸/闪避概率等额外机制。