_apply_homing 在位置积分前把速度矢量朝目标旋转,单帧转角上限 strength*delta, rotated() 保持速率不变;wrapf 取最短转向方向。 目标仅在失效或首帧时经 _find_nearest_unvisited 重选,配 get_active_count()==0 空场守卫,避免 query_circle(r=400) 进入每帧热路径。 bounce 命中重定向时改写 homing_target_id,由 bounce 选目标 homing 追上去。 删除 _enemy_pos_snapshot 及 EnemyManager.fill_pos_snapshot(全项目唯一调用方)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
256 lines
10 KiB
GDScript
256 lines
10 KiB
GDScript
## BulletManager — 子弹管理器 Autoload(GDScript 接口层)
|
||
## 热路径由 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 大小
|
||
|
||
# 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
|
||
if has_cold and _bullet_contexts.has(i):
|
||
_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 置 0;C# 下帧 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.4,SoA 索引 ↔ 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(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变
|
||
## 目标失效(死亡/未锁定)时重选一次;场上无敌人或射程内无目标则保持直行
|
||
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
|
||
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:
|
||
return
|
||
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()
|