Files
spellforge/docs_dev/specs/2026-07-24-player-iframes-design.md
T
joywayerandClaude Opus 4.8 383791db14 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>
2026-07-24 09:08:04 +08:00

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