# 玩家无敌帧（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.0，step 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`（保留，无害）。
- 不做无敌期间的碰撞体尺寸/闪避概率等额外机制。
