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