class_name BulletPool extends RefCounted ## Bullets are plain data in parallel arrays, never nodes. A bullet-hell spawns ## thousands of them per second; one Node2D + Area2D each would dominate both ## the frame budget and the network. Everything here is integrated by hand so ## the server can run it headless and a unit test can run it with no SceneTree. var pos := PackedVector2Array() var vel := PackedVector2Array() var radius := PackedFloat32Array() ## Scalar acceleration along the current heading, units/sec^2. var accel := PackedFloat32Array() ## Heading change per tick, radians. Non-zero makes the bullet curve. var turn := PackedFloat32Array() var life := PackedInt32Array() var damage := PackedInt32Array() ## Network identity. Stable for the bullet's whole life, 0 in a free slot. var uid := PackedInt32Array() var team := PackedByteArray() var kind := PackedByteArray() var alive := PackedByteArray() ## The geometry bullets die against. Set by the owning SimWorld; identical on ## server and client, which is what keeps the replica in step. var map: MapGrid = null var live_count := 0 ## Exclusive upper bound over slots that have ever been used, so the hot loops ## do not walk the whole 4096-slot table while the arena is nearly empty. var high_water := 0 var _free := PackedInt32Array() var _next_uid := 1 ## Slots spawned since the last [method clear_spawn_log]. The server turns this ## into BULLET_SPAWN events; the client replica ignores it because its bullets ## arrive from the wire already. var spawn_log := PackedInt32Array() ## uids of bullets killed by hitting solid geometry this tick. Lifetime and ## out-of-bounds deaths are NOT recorded: both sides can derive those from the ## spawn event alone. A wall death cannot be derived by a client that has not ## been streamed that wall yet, so the server announces those explicitly -- ## otherwise partial map knowledge shows bullets sailing through walls. var wall_kill_log := PackedInt32Array() func _init() -> void: var n := SimConfig.MAX_BULLETS pos.resize(n) vel.resize(n) radius.resize(n) accel.resize(n) turn.resize(n) life.resize(n) damage.resize(n) uid.resize(n) team.resize(n) kind.resize(n) alive.resize(n) func clear() -> void: for i in high_water: alive[i] = 0 uid[i] = 0 _free.clear() spawn_log.clear() wall_kill_log.clear() live_count = 0 high_water = 0 ## Allocate a slot. Returns the slot index, or -1 when the pool is saturated. ## [param forced_uid] is used by the client replica so its bullets carry the ## same identity the server assigned. func spawn(p: Vector2, v: Vector2, r: float, lifetime: int, dmg: int, bullet_team: int, bullet_kind: int, bullet_accel: float = 0.0, bullet_turn: float = 0.0, forced_uid: int = 0) -> int: var slot := -1 if not _free.is_empty(): slot = _free[_free.size() - 1] _free.remove_at(_free.size() - 1) elif high_water < SimConfig.MAX_BULLETS: slot = high_water high_water += 1 else: return -1 pos[slot] = p vel[slot] = v radius[slot] = r accel[slot] = bullet_accel turn[slot] = bullet_turn life[slot] = lifetime damage[slot] = dmg team[slot] = bullet_team kind[slot] = bullet_kind alive[slot] = 1 if forced_uid != 0: uid[slot] = forced_uid else: uid[slot] = _next_uid _next_uid += 1 live_count += 1 spawn_log.append(slot) return slot func clear_spawn_log() -> void: spawn_log.clear() wall_kill_log.clear() func despawn(slot: int) -> void: if alive[slot] == 0: return alive[slot] = 0 uid[slot] = 0 live_count -= 1 _free.append(slot) func find_by_uid(target_uid: int) -> int: for i in high_water: if alive[i] == 1 and uid[i] == target_uid: return i return -1 ## Advance every live bullet one tick. Bullets that expire or leave the arena ## die here on both server and client without any network traffic, because both ## sides run this identical integration. func step() -> void: var dt := SimConfig.TICK_DELTA for i in high_water: if alive[i] == 0: continue var v := vel[i] if turn[i] != 0.0: v = v.rotated(turn[i]) if accel[i] != 0.0: var speed := v.length() if speed > 0.0001: v = v * ((speed + accel[i] * dt) / speed) vel[i] = v var p: Vector2 = pos[i] + v * dt pos[i] = p life[i] -= 1 if life[i] <= 0: despawn(i) continue # Bounds first: out-of-bounds tiles read as WALL by design (that is what # seals the world), so testing geometry first would report every bullet # that simply left the map as a wall kill and announce it needlessly. if Movement.outside_map(p, map): despawn(i) continue if map != null and map.bullet_blocked(p): wall_kill_log.append(uid[i]) despawn(i) ## Fast-forward a single bullet. The client uses this so a bullet that spawned ## on the server N ticks ago appears where it should be now instead of popping ## in at the muzzle a round-trip late. func advance_slot(slot: int, ticks: int) -> void: var dt := SimConfig.TICK_DELTA for _i in ticks: if alive[slot] == 0: return var v := vel[slot] if turn[slot] != 0.0: v = v.rotated(turn[slot]) if accel[slot] != 0.0: var speed := v.length() if speed > 0.0001: v = v * ((speed + accel[slot] * dt) / speed) vel[slot] = v pos[slot] = pos[slot] + v * dt life[slot] -= 1 if life[slot] <= 0 or Movement.bullet_stopped(pos[slot], map): despawn(slot) return