## 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 大小 # 归航重选失败后的退避时长(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 置 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(弧度/秒) 为上限把速度矢量转向锁定目标,速率不变 ## 目标有效时零查询;目标失效(死亡)或首帧未锁定时经 _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。 # 整数除法是有意的:抖动只需毫秒粒度的偏移,小数部分丢弃即所需语义 @warning_ignore("integer_division") 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()