fix(homing): bounce 协同路径遗留过期退避键,使子弹对着尸体转向且拒绝重新锁定

homing_target_id 有两个写入方,不变式「homing_retry_at 只在目标无效时才有意义」
只在其中一方成立:_apply_homing 重选成功时会 erase 该键,bounce 那行不会。
后果是一颗 bounce+homing 子弹若先经历过一次重选失败、再弹跳到 next_id、而 next_id
随即死亡(弹跳目标是刚打过的敌人,随即死亡是常态),它会对着尸体继续转向,并在
残留退避窗口内(最多 400ms ≈ 24 帧)拒绝重新锁定,哪怕 50px 外就有活敌人。
这恰好废掉了那行 bounce 协同代码存在的意义。

修法与重选成功路径对称,补一行 cold.erase("homing_retry_at")。运行时复现确认:
  阶段1 重选失败 -> retry_at 存在=true, tgt=-1
  F bounce 后: tgt=3 (expect 3) retry_key_still_present=false   (修前 true)
  F 目标死亡后能否立刻重选: tgt=4 重选成功=true                  (修前被残留退避压住停在 3)

另两处:
- 调用方守卫注释的「省约 87%」改为 64% —— 87% 出自代理微基准推算,已被真实
  端到端实测(2.107→1.812ms)推翻,热路径里挂陈旧乐观数字会误导后来人判断该守卫去留。
- jitter 取模的 6 行不可达性论证压缩为一行(运算保留作索引越界兜底,成本为零)。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 16:26:17 +08:00
co-authored by Claude Opus 5
parent 4ef8012b6e
commit 28f4047096
+4 -3
View File
@@ -39,7 +39,7 @@ func _gd_integrate(delta: float) -> void:
while i < _active_count: while i < _active_count:
var base: int = i * BULLET_STRIDE var base: int = i * BULLET_STRIDE
# homing 判别在调用方完成:绝大多数冷数据子弹(纯 pierce/bounce/状态/荷载) # homing 判别在调用方完成:绝大多数冷数据子弹(纯 pierce/bounce/状态/荷载)
# 不归航,避免为它们付一次完整函数调用(实测 1500 弹省约 87% # 不归航,避免为它们付一次完整函数调用(实测 1500 弹省约 64%
if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"): if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"):
_apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进 _apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进
_data[base + 0] += _data[base + 2] * delta # x += vx * dt _data[base + 0] += _data[base + 2] * delta # x += vx * dt
@@ -123,6 +123,7 @@ func _check_collision(bullet_idx: int, base: int) -> bool:
_data[base + 7] *= float(cold.get("bounce_decay", 0.9)) _data[base + 7] *= float(cold.get("bounce_decay", 0.9))
if cold.has("homing_strength"): if cold.has("homing_strength"):
cold["homing_target_id"] = next_id # bounce 选目标,homing 接手追上去 cold["homing_target_id"] = next_id # bounce 选目标,homing 接手追上去
cold.erase("homing_retry_at") # 新目标有效,撤销退避(与重选成功路径对称)
cold["bounce_remaining"] = bounce - 1 cold["bounce_remaining"] = bounce - 1
cold["visited_targets"] = visited cold["visited_targets"] = visited
_bullet_contexts[bullet_idx] = cold _bullet_contexts[bullet_idx] = cold
@@ -258,9 +259,9 @@ func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
# 达 22.5ms,直接击穿 16.67ms 帧预算)。 # 达 22.5ms,直接击穿 16.67ms 帧预算)。
# 用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在 # 用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在
# 弹数少于窗口宽度时只铺开 _active_count 毫秒(20 颗弹 → 20ms ≈ 1.2 帧,等于没 # 弹数少于窗口宽度时只铺开 _active_count 毫秒(20 颗弹 → 20ms ≈ 1.2 帧,等于没
# 打散),占比式则不论弹数多少都铺满整窗。取模仅作越界兜底(循环内 bullet_idx # 打散),占比式则不论弹数多少都铺满整窗。到期时刻落在 [200, 400) ms。
# < _active_count 已保证不越界),使到期时刻无条件落在 [200, 400) ms。
var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count) var jitter: int = (bullet_idx * HOMING_RETRY_MS) / maxi(1, _active_count)
# 取模在循环内不可达(bullet_idx < _active_count ⟹ jitter ≤ 199),仅作索引越界兜底
cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS cold["homing_retry_at"] = now + HOMING_RETRY_MS + jitter % HOMING_RETRY_MS
return return
cold.erase("homing_retry_at") cold.erase("homing_retry_at")