Files
spellforge/docs_dev/specs/2026-07-30-bullet-homing-design.md
T
joywayerandClaude Opus 5 3e45ef0059 docs: 计划回填实际执行的验收脚本 + 订正权威文档自相矛盾处 + 性能数字改区间
【计划 Task 4】原稿四个脚本有缺陷,照它复跑会得到假失败与空通过,现已替换为
实际执行并通过的版本,每处旁注「原脚本为何有缺陷」,并勾上完成的复选框:
- Step 2 敌人摆位 (400,200) 距原点 447px 已出 homing_range=400,实测 FAILS=1;
  这是测试摆位错误而非实现缺陷(精确距离过滤行为正确)。改到 (300,150)。
- Step 6 内联复刻了折叠逻辑与商店谓词——在测自己;改调真实 _apply_modifier
  与 ShopManager._available_pool(),另补 compile_wand→execute_compiled 端到端。
- Step 7 无墙钟起搏,40 帧全停在退避窗内、守卫沦为空断言;且几何让子弹直穿
  敌人簇,多计约 1.5ms 碰撞开销。改为 60fps 起搏 + spec §5.2 环形摆位,
  frame1 与稳态峰值分开报。
- Step 8 只对源码 grep 字符串,不是往返;改为真实实例化 spell_tab 对真实
  spells.json 做 8 条 type-1 的回读→写回比对。
回溯表补「观测方式」列,标出⑥是唯一一条状态推断而非直接观测。

【architecture_design.md】同一文档内自相矛盾:
- CastStats 块 homing_force(「归航强度,向最近敌人偏转」)与 §4.2 新写的
  「最大转向角速度 + 锁定式目标」冲突 → 订正为 homing_add(弧度/秒);
  顺带订正确证过的 pierce_add / bounce_add(原写 *_count)。该块其余字段
  仍有既有漂移,加 ⚠️ 注明未核对、以 cast_stats.gd 为准,不扩大改动范围。
- ProjectileDef 的 homing_force 删除——projectile_def.gd 无任何 homing 字段,
  归航冷数据由 _push_projectile 直接写入 BulletManager。
- §4.2 bounce 协同散文补上实际存在的 if cold.has("homing_strength") 前置守卫。
- get_nearest_pos 代码片段:_enemy_count → _active_count(前者不存在)、
  未命中返回值 Vector2.ZERO → origin、删除不存在的 _visible_flags 过滤。

【spec】
- §4 第⑥条措辞「不触发 query_circle」字面为假(_check_collision 每帧无条件
  发一次),改为「不因归航触发」,并写明这是唯一一条间接验证及其封闭性论证。
- §5.5 峰值改为区间表述(① 4~5ms / ② 5~7ms @1500),注明编辑器/调试构建、
  运行间离散(同场景三次得 5.61/5.09/7.29ms),判定回归看是否出现周期性复发
  尖峰与是否越过 16.67ms,勿拿单值比对;并记录几何对测量的影响。
- §5.4 勘误:「1493 个互异到期值」不可能成立(jitter 只能产出约 200 个整数值,
  同帧失败又共用同一 now),复测为跨度 223ms / 222 个互异值,结论不变。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:33:16 +08:00

25 KiB
Raw Blame History

子弹归航(Homing)设计

日期2026-07-30 Epic:E1 战斗深度 · 子计划 ③(缺失功能路线图 docs_dev/plans/2026-07-23-missing-features-roadmap.md 优先级P0 · 工作量 S 目标:让子弹在飞行途中以受限角速度转向锁定的敌人(中度制导),激活 homing 深度维度;配 modifier_homing 词条。


1. 背景与现状(代码复核 2026-07-30)

  • 子弹热数据 SoABULLET_STRIDE=12),冷数据在 _bullet_contexts: Dictionarykey=bullet_idx)。现已消费 pierce_remaining / bounce_remaining / visited_targets无任何 homing 字段
  • bullet_manager.gd:22 声明 _enemy_pos_snapshot:31 每物理帧调 EnemyManager.fill_pos_snapshot 填充,但 _gd_integrate 从不读取 —— 零消费者的每帧 O(敌人数) 纯浪费。全项目 grep 确认 fill_pos_snapshot 只此一个调用方。
  • 修饰器管线已完备且被 pierce/bounce 两次验证:CastStats 累加字段 → _apply_modifier 折叠 meta → _push_projectile 写冷数据 → bullet_manager 消费。homing 完全镜像此路径,仅字段类型由 int 变 float。
  • EnemyManager.get_pos_by_id 对已死/不存在实体返回哨兵 (-9999,-9999)bullet_manager.gd:69 已在依赖此约定 —— 可直接用作「锁定目标已失效」的判据。
  • SpatialGrid.CELL_SIZE = 64query_circle 按半径覆盖的格子做三重循环,且每次调用都新建 PackedInt32Arrayspatial_grid.gd:42-45 注释:不可复用成员缓冲,嵌套调用会就地覆写,已实测复现)。半径 400 覆盖约 196 格,而现有碰撞查询半径 22 仅覆盖 1–2 格 —— 一次归航选目标 ≈ 一百多次碰撞查询,故选目标必须是低频操作(见 §2.4)。
  • GDScript 语义实测(编辑器内 lambda 验证,2026-07-30):PackedVector2Array 作函数参数是引用语义resize() 与元素写入均回传调用方。故 fill_pos_snapshot 本身工作正常,问题只是无人消费。

2. 设计

2.0 手感定位

中度制导:明显画弧追踪,但有最大转向角速度上限 —— 追不上贴脸急转的目标,会「甩尾」绕过去再回头。 反面:不做「轻度辅助瞄准」(玩家感知不到花了 8 蓝买了什么),也不做「每帧速度矢量直指目标」的强锁定(近乎必中,会让手瞄与闪避设计同时失效)。

转弯半径 = 速度 ÷ 角速度。主流子弹速度 ~350(spark_bolt 350 / fire_bolt 320 / frost_bolt 360):

角速度 350 速度下转弯半径 手感
2 rad/s 175 px 偏弱,大圆弧
3 rad/s(单层词条) 117 px 明显制导,急转的敌人能甩掉
6 rad/s(叠 2 层) 58 px 很难甩掉
12 rad/s(叠 4 层) 29 px 接近必中

不设叠加上限 —— 堆到必中是合法 build 收益,与 bounce 堆叠同理。

2.1 冷数据字段(子弹 _bullet_contexts[idx]

  • homing_strength: float —— 最大转向角速度,单位弧度/秒
  • homing_range: float(默认 400.0)—— 选目标搜索半径。给得比 bounce_range(250) 大,因为归航是「飞行途中找目标」而非「命中时找下一个」;但保持有限,空旷处子弹走直线、不会永远不落空。
  • homing_target_id: int —— 锁定的 entity_id-1 = 未锁定/待重选。

SoA 布局(BULLET_STRIDE=12不变。归航是稀疏特性,进冷数据符合既有分工。

2.2 CastStatscast_stats.gd

  • var homing_add: float = 0.0(紧邻 bounce_add+ reset() 置 0.0。
  • CIRCUIT 分支快照/还原spell_evaluator.gd _run_branch_payload)纳入 homing_add,注释「9 字段」→「10 字段」。

    注:现有快照块仍未含 pierce_addbounce 那期就记录的 pre-existing 泄漏,本期同样不修,出范围);homing_add 按正确做法纳入,与 bounce_add 一致。

2.3 修饰器折叠与发射

_apply_modifier

	if meta.has("homing"):
		ctx.stats.homing_add += float(meta["homing"])

_push_projectile(紧随现有 bounce 块):

	var homing: float = float(meta.get("homing", 0.0)) + ctx.stats.homing_add
	if homing > 0.0:
		cold["homing_strength"]  = homing
		cold["homing_range"]     = float(meta.get("homing_range", 400.0))
		cold["homing_target_id"] = -1

发射时不解析目标homing_target_id-1,第一帧在 _gd_integrate 惰性获取。这样 spell_evaluator 不必接触 SpatialGrid,选目标逻辑全项目只有一处。

2.4 目标获取与失效(低频查询策略)

目标有效(get_pos_by_id ≠ 哨兵)→ 直接用,零查询
目标失效或首帧(id < 0)      → 场上无敌人则直行;否则 _find_nearest_unvisited(pos, homing_range, visited)

稳态下一颗子弹一生只查 1–2 次(首帧获取 + 目标死亡后重选),开销可忽略。

空场守卫:重选前先判 EnemyManager.get_active_count() == 0 直接跳过,挡掉「清场瞬间所有在飞子弹集体重选且都查不到」这一最常见的病态帧。

补充(实测后修订):上面「一生只查 12 次」只在重选成功时成立;重选失败时会每帧重试,空场守卫挡不住。实测已越过触发线,故 §5 的退避方案(homing_retry_at)已随本期落地 —— 失败路径退避 200 ms 后再试。详见 §5。

复用现成的 _find_nearest_unvisited(bounce 期引入)—— 它已经滤除哨兵与已访问目标。纯归航子弹传 cold.get("visited_targets", [])(无该键时为空数组)。

2.5 转向积分(bullet_manager._gd_integrate,循环体顶部、位置积分之前)

前置:strength = float(cold["homing_strength"])tpos = EnemyManager.get_pos_by_id(tid)tid 由 §2.4 解析。tid < 0(重选失败/场上无敌人)时整段跳过,子弹按原速度直行。

	var vel := Vector2(_data[base + 2], _data[base + 3])
	var desired := (tpos - Vector2(_data[base + 0], _data[base + 1])).angle()
	var diff := wrapf(desired - vel.angle(), -PI, PI)          # 取最短转向方向
	var step := clampf(diff, -strength * delta, strength * delta)
	var nv := vel.rotated(step)
	_data[base + 2] = nv.x
	_data[base + 3] = nv.y

三个要点:

  1. rotated() 保持速率不变 —— 归航只改方向不改速度,与 bounce 重定向时用 spd 保持速率的处理一致。
  2. wrapf(..., -PI, PI) 保证走最短转向方向,不会为了追一个偏 179° 的目标而绕远路。
  3. clampfstrength * delta 即「最大角速度」,落实 §2.0 的中度制导。

放在位置积分之前,故子弹当帧即沿新方向前进。

外层守卫if not _bullet_contexts.is_empty() 后再逐弹 has()。纯普通子弹的额外成本是一次整数键哈希查找/帧。

2.6 与 bounce 协同

bounce 在命中瞬间把速度硬重定向到「最近未访问敌」(bullet_manager.gd:116),而 homing 每帧转向自己锁定的目标 —— 不协调的话命中后下一帧 homing 就会覆盖 bounce 的重定向bounce 直接失效。

语义:bounce 负责选目标,homing 负责追上去。 在 bounce 重定向块内追加一行,把 homing_target_id 改写为 bounce 刚选中的 next_id

			if cold.has("homing_strength"):
				cold["homing_target_id"] = next_id

bounce 现有行为一行不改(仍是命中瞬间硬重定向),homing 只接手其后的飞行段。两个词条叠加体感相乘而非互相抵消。

死循环规避homing 重选走 _find_nearest_unvisited,天然跳过 visited_targets。否则归航子弹会锁定一个已命中过的敌人,而命中循环的 visited 跳过守卫(bullet_manager.gd:78)让它永远打不中 —— 子弹绕着该敌人打转直到 lifetime 耗尽。

2.7 删除死代码

  • bullet_manager.gd:删 _enemy_pos_snapshot 成员(:22)、_physics_process 中的 fill_pos_snapshot 调用(:31)、文件头注释里的 homing 快照说明(:21)。
  • enemy_manager.gd:删 fill_pos_snapshot 函数本体(:345-350)—— grep 确认无其它调用方,保留即死代码。

净效果:减少一份每帧 O(敌人数) 开销

与路线图的偏离(有意):路线图 E1-③ 原文写「_gd_integrate 消费 _enemy_pos_snapshot」。实际设计选择了按 entity_id 锁定目标(轨迹可读、目标死亡有明确判据),而快照是按槽位存位置、不含 entity_id,无法支撑锁定语义。故快照不是「接上消费者」而是「删除」。路线图对应条目在实现完成后一并订正。

2.8 修饰器数据(data/spells.json

"modifier_homing": { "type": 1, "display_name": "Homing", "description": "后续法术获得归航(飞行中以受限角速度转向锁定的敌人)。", "element_tags": [], "meta": { "homing": 3.0, "mana_cost": 8, "shop_cost": 18 } }

mana_cost / shop_costmodifier_pierce_plusmodifier_bounce 完全对齐。shop_cost>0 → 自动进商店池。

不新增自带归航的 ACTION 法术 —— 只出 modifier_homing 一个词条,与 bounce 当期只加修饰器的做法一致。

2.9 设计器字段(addons/game_designer/spell_tab.gd

MODIFIERtype 1)的 meta schema 加:

	{"k": "homing", "l": "归航 homing", "w": "f", "d": 0.0, "opt": true},
	{"k": "bounce", "l": "弹跳 bounce", "w": "i", "d": 0, "opt": true},

bounce顺手补上的遗漏 —— bounce 那期只加了运行时,没加设计器字段,导致 bounce 键至今掉进「其它(JSON)」逃生舱,违反 CLAUDE.md「字段化,禁止手写 JSON」。同一处 schema、两行,属于本次动到的代码的定向修正,不扩散到无关重构。

3. 影响文件

文件 改动
scripts/domain/spell_system/cast_stats.gd homing_add 字段 + reset
scripts/domain/spell_system/spell_evaluator.gd _apply_modifier 折叠 + _push_projectile 写 cold + 分支快照/还原纳入 homing_add
scripts/autoloads/bullet_manager.gd _gd_integrate 转向段 + bounce 块改写 homing_target_id + 删快照成员/调用
scripts/autoloads/enemy_manager.gd fill_pos_snapshot
data/spells.json modifier_homing 词条
addons/game_designer/spell_tab.gd MODIFIER schema 加 homing + 补 bounce

4. 验收标准

全部经 Godot MCP 运行时实测,断言确定性数值而非目测:

  1. 归航生效homing=3.0 子弹朝偏移目标画弧并命中;homing=0 对照组直线飞过。断言速度矢量方向随帧变化(对照组不变)。
  2. 角速度上限:单帧转角 ≤ homing_strength * delta(浮点容差内)。
  3. 速率守恒:转向前后 Vector2(vx,vy).length() 不变。
  4. 最短转向:目标位于子弹正后方偏一侧时,转向方向为夹角较小的一侧(验证 wrapf)。
  5. 目标失效重选:锁定目标死亡后,下一帧 homing_target_id 变为另一存活敌人。
  6. 空场直行:场上无敌人时子弹保持直线,且不因归航触发 query_circle

    措辞修正(2026-07-30 验收期):原写「不触发 query_circle」,字面为假 —— _check_collisionbullet_manager.gd:69)每帧对每颗子弹无条件发一次 query_circle(pos, radius+16),与归航无关。被验证的命题是「_apply_homing 这条路径不发查询」。

    ⚠️ 本条是全部 10 条验收里唯一的间接验证,值得留痕。无法给 autoload 的方法挂调用计数器,故改用状态判据:空场跑 6 帧后断言 homing_target_id 未变且冷数据无 homing_retry_at 键。其封闭性论证 ——

    1. _apply_homing 内通往 SpatialGrid 的路径有且仅有一处 _find_nearest_unvisited,无其它 SpatialGrid 访问;
    2. 该调用之后必然二选一:成功则写 homing_target_id(变为 ≥0 的新值),失败则写 homing_retry_at
    3. 两者皆未发生 ⟹ 该调用未被执行 ⟹ 归航未发查询;
    4. 零敌人时 _check_collision:71hits.is_empty())即返回,bounce 路径不可达,不会与之混淆。

    论证对被限定后的命题成立,但它是状态推断而非对调用次数的直接观测 —— 如实标注,不宣称为直接测量。

  7. bounce 协同homing + bounce 子弹弹跳后 homing_target_id == bounce 选中的 next_id
  8. 修饰器折叠:两层 modifier_homingctx.stats.homing_add == 6.0
  9. 商店与设计器modifier_homing 可在商店买到;设计器 homing/bounce 字段回读往返一致(对真实 data/spells.json)。
  10. validate_script 通过;纯数据驱动;无目标时不崩。

5. R-H1 选目标频率尖峰 —— 已实测触发并启用逃生方案

状态更新(2026-07-30Task 2 代码质量评审后):本节原写「方案 3 暂不实现」,等验收期实测再定。评审在 Task 2 上直接实测,两种病态状态在 300 弹规模就已越过本节写死的 2.0 ms 触发线,故方案 3 已随 Task 2 落地,不再等待。以下如实记录,含原始风险分析的不完整之处。

5.1 病态状态(其二为本次新发现)

重选失败时原实现不留任何记录,于是该子弹余生每帧都重跑 _find_nearest_unvisitedquery_circle(pos, 400)(约 196 格 + 一次 PackedInt32Array 分配)。get_active_count() == 0 空场守卫只挡住「全场清空」—— 而这是本游戏里最罕见的状态,两种日常状态都没挡住:

  1. 敌人在场但全在 homing_range(玩家朝空处开火)—— 本节原文已预见。
  2. 射程内敌人全在 visited_targets —— 原风险分析漏掉了这一种,是本次发现,且比第 1 种更糟bounce+homing 组合弹打空一个小簇后 visited_targets 只增不减,对那颗子弹是永久状态而非瞬态;同时 visited 的线性 in 扫描叠加在 query_circle 之上,单弹成本更高。

原分析把 R-H1 定性为「瞬态尖峰」是不准确的 —— 存在使其长期化的路径。

5.2 实测(启用前 vs 启用后)

测法:运行时直调 BulletManager._gd_integrate(1/60) 计时,20 帧取均值。30 个敌人置于环上(far 环半径 3000visited 环半径 300 —— 在 homing_range 400 内但在碰撞半径 20 外),子弹簇于原点附近,homing_strength=3.0homing_range=400。对照基线为同规模无冷数据普通子弹。

场景 1500 弹(启用前) 300 弹(启用前) 1500 弹(启用后·摊薄) 300 弹(启用后·摊薄)
基线(普通子弹,无归航) 1.630 ms 0.324 ms 1.642 ms 0.324 ms
① 敌人全在射程外 15.790 ms 3.135 ms 3.939 ms 0.784 ms
② 射程内全在 visited 21.965 ms 4.341 ms 4.518 ms 0.884 ms

单弹成本:① 10.53 µs/帧、② 14.64 µs/帧。两种场景在 300 弹规模分别为 3.14 / 4.34 ms均已越过本节 2.0 ms 触发线,故触发条件成立。

「启用后」的摊薄值按 60fps 下 12 帧中 1 帧付全价计算 (重选帧 + 11 × 退避帧) / 12。分解:

场景 · 规模 退避帧(零查询) 重选帧(全价) 摊薄
① 1500 弹 2.856 ms 15.851 ms 3.939 ms
① 300 弹 0.567 ms 3.167 ms 0.784 ms
② 1500 弹 2.915 ms 22.150 ms 4.518 ms
② 300 弹 0.570 ms 4.340 ms 0.884 ms

5.3 已启用的方案 3(退避 + 抖动)

重选失败时往冷数据写 homing_retry_at,窗口内子弹直行且不再查询,60fps 下把最坏情况摊薄约 12×。要点:

  • 首次锁定仍是即时的 —— 无该键时 get 返回 0 必小于 now,退避只作用于失败路径,§2.0 的手感不回退(运行时已断言首帧即锁定且不写退避键)。
  • 重选成功erase("homing_retry_at"),恢复 §2.4 的零查询稳态。
  • 顺带同期落地:homing_strength 判别提到 _gd_integrate 调用方,带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再付一次完整函数调用 —— 1500 纯 pierce 弹 2.107 → 1.812 ms。

5.4 二次发现:朴素退避会同步,而非打散(本次最值得记住的一条)

落地固定 200 ms 退避后按真实 60fps 起搏复测(每轮补足 16.67 ms 墙钟,只对 _gd_integrate 计时),发现尖峰并未消失,只是变成周期性复发。1500 弹 visited 场景逐帧耗时(ms):

抖动前: 21.9 2.8 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.9 2.8 3.1 | 21.8 2.8 ... | 21.0 2.9 ... | 20.9 3.7 3.9 3.8
          ↑第1帧                                             ↑第13帧        ↑第25帧        ↑第37帧

恰好每 12 帧复发一次,永不衰减。 根因:同一帧失败的所有子弹拿到同一个 now,到期时刻完全相同,一个退避周期后又整齐地一起重试。退避把子弹锁进同相,而不是打散它们 —— 同波齐射本就天然同相,此后再无机会错开。设计退避/重试逻辑时若不显式加抖动,默认结果就是同步而非分散;这与「退避能摊薄尖峰」的直觉相反,摊薄后的均值数字恰恰会掩盖它。

修法:抖动到期时刻_apply_homing 失败路径):

	var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
	cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS

到期时刻均匀铺满 [200, 400) ms ≈ 恰好一个退避周期 ≈ 12 帧。用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在弹数少于窗口宽度时只能铺开 _active_count 毫秒(20 颗弹 → 20 ms ≈ 1.2 帧,等于没打散),占比式则不论弹数多少都铺满整窗。HOMING_RETRY_MS 一值两用且刻意耦合 —— 既是基础退避时长也是抖动窗口宽度,「铺满恰好一个退避周期」正是消峰所需的性质,拆成两个常量反而掩盖这层关系。

实测打散质量(2026-07-30 验收期复测):1500 颗同帧集体失败 → 到期时刻跨度 223 ms(≈ 恰好一个退避周期,即 13~14 帧)、222 个互异毫秒值,平均每毫秒约 6.8 颗、每帧约 110 颗(理想 500/12 = 125)。

勘误:本节初稿写「1493 个互异值」,该数不可能成立 —— jitter = (bullet_idx * 200) / _active_count 对 1500 颗弹只能产出 0~199 共约 200 个整数值,同帧失败的子弹又共用同一个 now,故互异到期时刻上限就是 ~200 个。复测得 222,多出的 22 来自「遍历这 1500 颗本身耗时约 23 ms,期间 now 在推进」(与 §5.6 记录的首帧 23 ms 吻合)。打散结论不变(跨度铺满一个完整退避周期即消峰所需性质),只是原先那个数字是错的。

5.5 抖动前后单帧峰值对比(60fps 起搏 · 40 帧)

场景 · 规模 抖动前·复发尖峰 抖动后·稳态峰值(区间) 稳态超 16.67ms 帧数
① 全射程外 · 1500 13.6 ~ 15.7 ms(每 12 帧复发) 4 ~ 5 ms 0/39
① 全射程外 · 300 3.2 ms 约 1 ms 0/39
② 全 visited · 1500 20.9 ~ 21.9 ms(每 12 帧复发) 5 ~ 7 ms 0/39
② 全 visited · 300 4.6 ms 1 ~ 2.5 ms 0/39

复发尖峰彻底消除,稳态全部回到帧预算内。

⚠️ 数字按区间读,不要按单值读:以上均为编辑器/调试构建实测,运行间存在明显离散。同一场景 ②·1500 三次独立测量分别得 5.61 / 5.09 / 7.29 ms;240 帧长跑收敛在 5–7 ms 带内、无发散。写成单值会显得比数据本身更精确 —— 结论(稳态远低于 16.67 ms 帧预算、无复发尖峰)不受此离散影响,但不要引用单个数字做后续推算,也不要因复跑得到带内的不同值就判定为回归。判定回归请看:是否出现周期性复发尖峰、是否有帧越过 16.67 ms。

另注:稳态峰值对测试几何敏感。若子弹航线穿过敌人簇,_check_collisionquery_circle + visited_targets 线性扫描会额外贡献 1~1.5 ms,那部分不是归航成本(验收期曾因此误读一次)。上表摆位为「敌人置于环上、子弹簇于原点」,刻意让子弹不穿过敌人簇。

5.6 残余:初次锁定帧 —— 已决策:接受,不处理

抖动后仍有一个高耗时帧 —— 第 1 帧,1500 弹 far 16.2 ms / visited 22.0 ms。它不是退避尖峰,而是「§2.4 首次锁定即时、不受退避约束」的直接结果:1500 颗归航子弹在同一帧被创建时,它们的首次 query_circle 必然落在同一帧。抖动按设计不作用于此(已断言首帧即时锁定且不写退避键,手感不回退)。

触发条件苛刻:需同一帧创建约 1500 颗归航子弹且首次选目标全部失败。典型局面下齐射规模远小于此,且失败还需叠加「全射程外」或「全 visited」。

决策(2026-07-30):接受,本期不处理。 理由三条:

  1. 触发条件不是真实局面。 1500 弹是全游戏子弹池的压力上限(MAX_BULLETS=2048),要求它们全部归航、同帧创建、且首次选目标全部失败 —— 这是三个独立苛刻条件的合取,属合成的最坏情况而非可达的玩法状态。项目实测基准是 500 敌 + 1500 弹时整个 _physics_process ≈ 0.6 ms(帧预算 3.6%)。
  2. 它一次性、不复发。 与被修掉的那个每 12 帧永久复发的尖峰性质不同 —— 后者是持续卡顿,前者最多是一次可忽略的掉帧。
  3. 压下它的代价是手感回退或语义变更。 三条可行路径(首帧也抖动 / 连续失败 N 次后彻底放弃归航 / 给 visited_targets 设上限)分别牺牲「首次锁定即时」、改变归航语义、改变 bounce 语义。按 CLAUDE.md「先测量后优化,不做无凭据的过早优化」,为一个合成场景付这个代价不划算。

重新立项的触发条件(按弹数算,不要按场景猜):成本严格线性于「同一帧创建、且首次选目标全部失败的归航弹数」,约 9.5 µs/颗。据此可直接算出击穿点,无需揣摩「典型齐射规模」:

同帧首选失败的归航弹数 该帧额外耗时 判断
200 ≈ 1.9 ms 无感
500 ≈ 4.8 ms 可接受
1000 ≈ 9.5 ms 接近半帧,警戒
1750 ≈ 16.7 ms 满帧预算,必然掉帧

约 1750 颗是理论击穿点(MAX_BULLETS = 2048,故容量内可达)。注意实测 1500 颗 visited 场景为 22.0 ms,高于线性外推的 14.3 ms —— 差额来自 visited_targets 的线性 in 扫描,故 ② 类场景的实际击穿点更低。

当同帧齐射的归航弹数逼近千级时重新立项,按上述三条路径之一处理。

5.7 潜在项(当前休眠,不修):退避用墙钟,不免疫时间缩放

退避基于 Time.get_ticks_msec() —— 墙钟,不受 Engine.time_scale 影响。而 TimeManager 正是为「慢动作 / 冻结时间 Core 效果」驱动 Engine.time_scale 的(time_manager.gd:4,16)。

当前不是问题grep 确认 TimeManager.set_time_scale 全项目零调用方(仅 time_manager.gd:23 定义),慢动作 Core 尚未落地。

若日后落地:设 0.2× 慢动作,200 ms 墙钟只跨约 2.4 个物理帧而非 12,每帧重选量涨约 5×,§5.4 的抖动摊薄基本失效 —— 且表现为慢动作期间掉帧,即最不该掉帧的时候掉。

替代方案(触发时再实施,现在不动):改用物理帧计数而非墙钟 —— HOMING_RETRY_FRAMES: int = 12,抖动公式变成帧原生的 (bullet_idx * HOMING_RETRY_FRAMES) / maxi(1, _active_count)。天然免疫时间缩放、比 Time.get_ticks_msec() 略便宜,并消除 ms ↔ 帧的量纲错配(现在「200 ms ≈ 12 帧」只在 60fps 且 time_scale == 1 时成立)。本项目已有现成计数器 TimeManager.game_ticktime_manager.gd:9,15),其注释明写「不受 time_scale 影响」,正合用。

触发条件:慢动作 / 冻结时间 Core 落地时,即 set_time_scale 出现第一个调用方。

6. 非目标(YAGNI

  • 轨迹修正 / 曲线弹道(E1 子计划 ④,角速度常量而非寻的)—— 独立立项。
  • 自带归航的 ACTION 法术 —— 本期只出修饰器。
  • 归航拖尾 / 锁定指示 VFX —— 沿用现有 hit_spark
  • 敌方子弹归航EnemyBulletManager)—— 本期仅玩家子弹。
  • C# 热路径 —— GDScript 足够(500 敌 3.6% 预算),且本期净减开销。
  • 修复 pierce_add 的分支快照遗漏 —— pre-existing,出本期范围。
  • 修复 acceleration 积分错误 —— _gd_integrate:42-43 对 vx/vy 各自加同一标量 accel*delta(应为沿速度方向加速)。当前无任何法术写入非零 acceleration,故为惰性死代码;会在 E1-④ 轨迹修正启用时暴露。出本期范围,已另行记录。