Files
spellforge/scripts/autoloads/bullet_manager.gd
T
joywayerandClaude Opus 5 c9786eba50 fix(bullet): _swap_and_pop 回收末尾子弹时遗留冷数据,被下一颗复用槽位的子弹继承
pre-existing bug(非本期归航引入):冷数据的搬移与擦除都写在 if idx != last 分支内,
故 idx == last(回收最后一颗子弹)时它的 _bullet_contexts[idx] 被原样留在该 key 上,
下一颗复用该槽位的普通子弹会继承整份冷数据。运行时已复现:
  after recycle: active=0 ctx_keys=[0]
  plain bullet idx=0 inherits_cold=true cold={"homing_strength":3.0,...,"pierce_remaining":7}

归航之前症状只是一次隐形的多余穿透,故长期未被发现;归航把它放大成肉眼可见的
「普通子弹拐弯追敌」,且继承 homing 的幽灵弹会持续走重选路径付出查询开销。
修法是把 erase 移出该 if(缺键时 erase 为安全 no-op),idx != last 的搬移行为一行不改。

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

272 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)
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:
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()