Files
spellforge/docs_dev/specs/2026-07-24-player-iframes-design.md
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

5.3 KiB
Raw Permalink Blame History

玩家无敌帧(i-frames)设计

日期2026-07-24 EpicE6-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) 事件当前无人 emitgrep 零匹配):
    • audio_manager.gd:36 订阅它播 hurt 音效 → 死代码,从不触发
    • player_stats.gd:33 自订阅 PLAYER_DAMAGED → _on_player_damaged → take_damage死代码(无人 emit)。
  • 自伤入口 spend_hp_costplayer_stats.gd:108infinite_spells 卖血)与外部伤害语义独立,不应受无敌帧影响。
  • 玩家可视节点为 combat_s2.gd:_setup_player() 创建的 Node2D(含青色菱形 Polygon2D + 光晕),存于 PlayerManager._player_nodemodulate 可用于闪烁。

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:33EventBus.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」页新增一栏 SpinBox0.0~2.0step 0.05),复用 designer_ui.gdUI.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(保留,无害)。
  • 不做无敌期间的碰撞体尺寸/闪避概率等额外机制。