Files
spellforge/scripts/autoloads/bullet_manager.gd
T
joywayerandClaude Opus 5 28f4047096 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>
2026-07-30 16:26:17 +08:00

285 lines
13 KiB
GDScript
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.
## BulletManager — 子弹管理器 AutoloadGDScript 接口层)
## 热路径由 C# BulletManagerCs 子节点驱动。
## 权威来源:architecture_design.md §4.2
##
## SoA 完整布局(BULLET_STRIDE=12):
## [ x, y, vx, vy, lifetime, radius, base_damage, damage_mult, damage_type, owner_id, source_tags, acceleration ]
## 0 1 2 3 4 5 6 7 8 9 10 11
## 冷数据(pierce/bounce/homing/payload_id)→ _bullet_contexts: Dictionary
extends Node
const BULLET_STRIDE: int = 12 # 禁止裸整数 12(跨切片约束)
const MAX_BULLETS: int = 2048 # 预分配 SoA 大小
# 归航重选失败后的退避时长(60fps 下把最坏情况摊薄约 12×)。
# 一值两用且刻意耦合:既是基础退避时长,也是抖动窗口宽度 —— 到期时刻均匀铺满
# 「恰好一个退避周期」正是消除集体重试尖峰所需的性质,拆成两个常量反而会掩盖这层关系。
const HOMING_RETRY_MS: int = 200
# SoA 热数组(由 BulletManagerCs 直接读写)
var _data: PackedFloat32Array = PackedFloat32Array()
var _active_count: int = 0
# 冷数据(非热路径)
var _bullet_contexts: Dictionary = {}
var _cs_node: Node = null
func _ready() -> void:
_data.resize(MAX_BULLETS * BULLET_STRIDE)
_data.fill(0.0)
func _physics_process(delta: float) -> void:
if not _cs_node:
_gd_integrate(delta)
# GDScript 回退路径(C# 未就绪时 / S0/S1 基线验证)
func _gd_integrate(delta: float) -> void:
var has_cold: bool = not _bullet_contexts.is_empty()
var i: int = 0
while i < _active_count:
var base: int = i * BULLET_STRIDE
# homing 判别在调用方完成:绝大多数冷数据子弹(纯 pierce/bounce/状态/荷载)
# 不归航,避免为它们付一次完整函数调用(实测 1500 弹省约 64%)
if has_cold and _bullet_contexts.has(i) and _bullet_contexts[i].has("homing_strength"):
_apply_homing(i, base, delta) # 转向须在位置积分之前,子弹当帧即沿新方向前进
_data[base + 0] += _data[base + 2] * delta # x += vx * dt
_data[base + 1] += _data[base + 3] * delta # y += vy * dt
_data[base + 2] += _data[base + 11] * delta # vx += accel * dt
_data[base + 3] += _data[base + 11] * delta # vy += accel * dt
_data[base + 4] -= delta # lifetime -= dt
if _data[base + 4] <= 0.0:
_swap_and_pop(i)
elif _check_collision(i, base):
# 命中处理已在 _check_collision 内完成,子弹被回收
pass # _active_count 已减少,i 不递增
else:
i += 1
# S1 碰撞检测:数据驱动,使用 SpatialGrid 查询
# 命中后扣血;pierce_remaining=0 时回收子弹;返回 true 表示子弹已被回收
func _check_collision(bullet_idx: int, base: int) -> bool:
var bx: float = _data[base + 0]
var by: float = _data[base + 1]
var br: float = _data[base + 5] # bullet radius
var b_dmg: float = _data[base + 6] # base_damage
var b_mult: float = _data[base + 7] # damage_mult
var b_type: int = int(_data[base + 8])
var b_own: int = int(_data[base + 9])
var query_r: float = br + 16.0 # 16px = 敌人碰撞体估算半径
var hits: PackedInt32Array = SpatialGrid.query_circle(Vector2(bx, by), query_r)
if hits.is_empty():
return false
for entity_id in hits:
var ene_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if ene_pos == Vector2(-9999.0, -9999.0):
continue
var dx: float = bx - ene_pos.x
var dy: float = by - ene_pos.y
var dist_sq: float = dx * dx + dy * dy
if dist_sq > query_r * query_r:
continue
if _bullet_contexts.has(bullet_idx):
var vt = _bullet_contexts[bullet_idx].get("visited_targets", null)
if vt != null and entity_id in vt:
continue # 弹跳已命中过此敌:跳过,避免密集群内重复命中
# 命中:通过 DamageContextPool 发出伤害
var ctx_id: int = DamageContextPool.acquire(b_dmg, b_mult, b_type, b_own, false, 0.0)
EnemyManager.apply_damage_from_context(entity_id, ctx_id)
DamageContextPool.release(ctx_id)
var hit_pos: Vector2 = EnemyManager.get_pos_by_id(entity_id)
if hit_pos == Vector2(-9999.0, -9999.0):
hit_pos = Vector2(bx, by)
VFXManager.play("hit_spark", hit_pos)
EventBus.emit(EventID.BULLET_HIT, {"bullet_id": bullet_idx, "target_id": entity_id, "hit_pos": hit_pos})
# 无冷数据的普通子弹:无状态/荷载/穿透,直接回收
# (避免 .get(bullet_idx, {}) 每次命中都分配一个空字典默认值)
if not _bullet_contexts.has(bullet_idx):
_swap_and_pop(bullet_idx)
return true
# S4: 命中施加状态 / 连击标记
var cold: Dictionary = _bullet_contexts[bullet_idx]
var status_id: int = int(cold.get("apply_status_id", -1))
if status_id > 0:
StatusManager.apply(entity_id, status_id, 1, -1.0, b_own)
if cold.get("apply_combo_mark", false):
StatusManager.apply(entity_id, StatusID.COMBO_MARK, 1, 2.0, b_own)
# S3: 命中触发子荷载
var payload_id: int = int(cold.get("on_hit_payload_id", -1))
if payload_id >= 0:
var parent_depth: int = int(cold.get("trigger_parent_depth", 0))
SpellEvaluator.execute_sub(payload_id, hit_pos, b_own, parent_depth + 1)
# bounce 处理(优先于 pierce):命中后弹向最近未访问敌人,伤害递减
var bounce: int = int(cold.get("bounce_remaining", 0))
if bounce > 0:
var visited: Array = cold.get("visited_targets", [])
visited.append(entity_id)
var next_id: int = _find_nearest_unvisited(Vector2(bx, by), float(cold.get("bounce_range", 250.0)), visited)
if next_id >= 0:
var np: Vector2 = EnemyManager.get_pos_by_id(next_id)
var spd: float = Vector2(_data[base + 2], _data[base + 3]).length()
var dir: Vector2 = (np - Vector2(bx, by)).normalized()
_data[base + 2] = dir.x * spd
_data[base + 3] = dir.y * spd
_data[base + 7] *= float(cold.get("bounce_decay", 0.9))
if cold.has("homing_strength"):
cold["homing_target_id"] = next_id # bounce 选目标,homing 接手追上去
cold.erase("homing_retry_at") # 新目标有效,撤销退避(与重选成功路径对称)
cold["bounce_remaining"] = bounce - 1
cold["visited_targets"] = visited
_bullet_contexts[bullet_idx] = cold
return false
_swap_and_pop(bullet_idx)
return true
# pierce 处理
var pierce: int = int(cold.get("pierce_remaining", 0))
if pierce > 0:
cold["pierce_remaining"] = pierce - 1
_bullet_contexts[bullet_idx] = cold
return false # 穿透:子弹继续飞行
_swap_and_pop(bullet_idx)
return true # 子弹已回收
return false
func _swap_and_pop(idx: int) -> void:
var last: int = _active_count - 1
if idx != last:
var base_idx: int = idx * BULLET_STRIDE
var base_last: int = last * BULLET_STRIDE
for s in BULLET_STRIDE:
_data[base_idx + s] = _data[base_last + s]
if _bullet_contexts.has(last):
_bullet_contexts[idx] = _bullet_contexts[last]
_bullet_contexts.erase(last)
elif _bullet_contexts.has(idx):
_bullet_contexts.erase(idx)
else:
# idx == last:无搬移,但它的冷数据同样必须清掉,
# 否则遗留在该 key 上被下一颗复用此槽位的子弹继承(erase 缺键为安全 no-op
_bullet_contexts.erase(idx)
_active_count -= 1
# ── 对外接口 ─────────────────────────────────────────────────────
## 生成子弹,返回 bullet_id(比 -1 表示满容)
## 参数附带冷数据:cold_data = {"on_hit_payload_id": int, "pierce_remaining": int, ...}
func spawn_bullet(pos: Vector2, vel: Vector2, lifetime: float, radius: float,
base_damage: float, damage_mult: float, damage_type: int,
owner_id: int, source_tags: int = 0, acceleration: float = 0.0,
cold_data: Dictionary = {}) -> int:
if _active_count >= MAX_BULLETS:
return -1
var bullet_id: int = _active_count
var base: int = bullet_id * BULLET_STRIDE
_data[base + 0] = pos.x
_data[base + 1] = pos.y
_data[base + 2] = vel.x
_data[base + 3] = vel.y
_data[base + 4] = lifetime
_data[base + 5] = radius
_data[base + 6] = base_damage
_data[base + 7] = damage_mult
_data[base + 8] = float(damage_type)
_data[base + 9] = float(owner_id)
_data[base + 10] = float(source_tags)
_data[base + 11] = acceleration
if not cold_data.is_empty():
_bullet_contexts[bullet_id] = cold_data
_active_count += 1
return bullet_id
## 提前回收:lifetime 置 0C# 下帧 SoA 清理
func despawn_bullet(bullet_id: int) -> void:
if bullet_id < 0 or bullet_id >= _active_count:
return
_data[bullet_id * BULLET_STRIDE + 4] = 0.0 # slot +4: lifetime
func get_active_count() -> int:
return _active_count
## 渲染:将活跃子弹的 SoA 位置批量写入 MultiMesh(§4.4SoA 索引 ↔ instance 索引共享)
## visible_instance_count 控制渲染数量,避免每帧重分配 instance_count
func sync_multimesh(mm: MultiMesh) -> void:
if mm == null:
return
var n: int = min(_active_count, mm.instance_count)
mm.visible_instance_count = n
for i in n:
var base: int = i * BULLET_STRIDE
var r: float = _data[base + 5]
mm.set_instance_transform_2d(i, Transform2D(
0.0, Vector2(r * 2.0, r * 2.0), 0.0,
Vector2(_data[base + 0], _data[base + 1])))
## 弹跳/归航寻的:query_circle 半径内滤除已访问/哨兵,取最近 entity_id;无则 -1
func _find_nearest_unvisited(origin: Vector2, radius: float, visited: Array) -> int:
var hits: PackedInt32Array = SpatialGrid.query_circle(origin, radius)
var best_id: int = -1
var best_sq: float = radius * radius
for eid in hits:
if eid in visited:
continue
var p: Vector2 = EnemyManager.get_pos_by_id(eid)
if p == Vector2(-9999.0, -9999.0):
continue
var d: float = origin.distance_squared_to(p)
if d <= best_sq:
best_sq = d
best_id = eid
return best_id
## 归航转向:以 homing_strength(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变
## 目标有效时零查询;目标失效(死亡)或首帧未锁定时经 _find_nearest_unvisited 重选。
## 重选成功即恢复零查询稳态;重选失败(场上无敌人 / 射程内无目标 / 射程内全在
## visited_targets 里)则子弹直行,并记 homing_retry_at 退避 HOMING_RETRY_MS 后再试 ——
## 否则该子弹余生每帧都会重跑 query_circle(r=400)(实测 ~10.5µs/帧/弹,1500 弹达 15.8ms)。
## 首次锁定不受退避影响(无该键时 get 返回 0 必小于 now),手感不回退。
func _apply_homing(bullet_idx: int, base: int, delta: float) -> void:
var cold: Dictionary = _bullet_contexts[bullet_idx]
var strength: float = float(cold.get("homing_strength", 0.0))
if strength <= 0.0:
return
var pos: Vector2 = Vector2(_data[base + 0], _data[base + 1])
var tid: int = int(cold.get("homing_target_id", -1))
var tpos: Vector2 = Vector2(-9999.0, -9999.0)
if tid >= 0:
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0):
# 目标失效或首帧:重选。空场守卫避免清场瞬间全体子弹集体 query_circle
if EnemyManager.get_active_count() == 0:
return
var now: int = Time.get_ticks_msec()
if now < int(cold.get("homing_retry_at", 0)):
return # 上次重选失败,退避中:本帧直行,不查询
tid = _find_nearest_unvisited(pos, float(cold.get("homing_range", 400.0)), cold.get("visited_targets", []))
cold["homing_target_id"] = tid
_bullet_contexts[bullet_idx] = cold
if tid < 0:
# 抖动「到期时刻」而非让所有子弹共用固定 HOMING_RETRY_MS:同帧集体失败的子弹
# 拿到同一个 now,退避期完全一致,12 帧后又整齐地一起重试 —— 退避会同步而非
# 打散,同波齐射天然同相故尖峰永不消散(实测 1500 弹 visited 场景 40 帧里 4 帧
# 达 22.5ms,直接击穿 16.67ms 帧预算)。
# 用「bullet_idx 在活跃弹数中的占比」而非 bullet_idx % HOMING_RETRY_MS:后者在
# 弹数少于窗口宽度时只铺开 _active_count 毫秒(20 颗弹 → 20ms ≈ 1.2 帧,等于没
# 打散),占比式则不论弹数多少都铺满整窗。到期时刻落在 [200, 400) ms。
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
return
cold.erase("homing_retry_at")
tpos = EnemyManager.get_pos_by_id(tid)
if tpos == Vector2(-9999.0, -9999.0):
return
var vel: Vector2 = Vector2(_data[base + 2], _data[base + 3])
var diff: float = wrapf((tpos - pos).angle() - vel.angle(), -PI, PI) # 最短转向方向
var max_step: float = strength * delta
var nv: Vector2 = vel.rotated(clampf(diff, -max_step, max_step)) # rotated 保持速率不变
_data[base + 2] = nv.x
_data[base + 3] = nv.y
func get_nearest_enemy_pos(origin: Vector2, max_dist: float = 9999.0) -> Vector2:
return EnemyManager.get_nearest_pos(origin, max_dist)
func reset() -> void:
_active_count = 0
_data.fill(0.0)
_bullet_contexts.clear()