缺失功能路线图 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>
82 lines
5.3 KiB
Markdown
82 lines
5.3 KiB
Markdown
# 玩家无敌帧(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`(保留,无害)。
|
||
- 不做无敌期间的碰撞体尺寸/闪避概率等额外机制。
|