Files
spellforge/scripts/autoloads/bullet_manager.gd
T
joywayerandClaude Opus 5 2f9cb76308 perf(homing): 重选失败退避 0.2s + homing 判别提到调用方
评审实测发现两种病态状态下重选会每帧无限重试,query_circle(r=400) 进入热路径:
① 敌人在场但全在 homing_range 外;② 射程内敌人全在 visited_targets 里
(bounce+homing 打空小簇后是该子弹的永久状态,原 spec 风险分析漏掉了这种)。
1500 弹实测 15.8ms / 22.0ms,300 弹 3.1ms / 4.3ms,均越过 spec §5 的 2.0ms 触发线,
故 spec §5 记录的逃生方案(方案 3)提前落地:重选失败写 homing_retry_at 时间戳,
HOMING_RETRY_MS(200ms) 内直行不再查询,60fps 下摊薄约 12×。
首次锁定不受影响(无该键时 get 返回 0 必小于 now),手感不回退;
重选成功即 erase 该键,恢复零查询稳态。

homing 判别 _bullet_contexts[i].has("homing_strength") 提到 _gd_integrate 调用方:
带冷数据但不归航的子弹(纯 pierce/bounce/状态/荷载,真实 build 里占多数)不再
为发现自己不归航而付一次完整函数调用 —— 1500 纯 pierce 弹实测 2.107→1.812ms。
_apply_homing 内的 strength <= 0.0 早退保留作防御性下限。

同步订正两处失准注释:_find_nearest_unvisited 现也是归航选目标器;
_apply_homing 文档注释补齐失败路径的重试/退避策略。

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

268 lines
12 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 大小
const HOMING_RETRY_MS: int = 200 # 归航重选失败后的退避时长(60fps 下把最坏情况摊薄约 12×)
# 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 弹省约 87%)
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["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)
_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:
cold["homing_retry_at"] = now + 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()