7af439341d
ci / verify (push) Successful in 46s
Replaces the fixed centred arena with per-world tile geometry, which is the foundation the remaining features sit on. - MapGrid: tile grid with three independent flags -- blocks movement, bullets, sight -- so a pit stops feet but not bullets or eyes, and a barricade stops feet and bullets but not eyes. Circle collision with per-axis sliding, and Bresenham line of sight shared by fog and aggro. - MapGen: rooms and corridors generated from (seed, depth), with hand-authored boss arenas from Rooms stamped in first so a corridor can never carve through a designed fight. Reachability from spawn to boss is asserted over 40 seeds -- "usually connected" is the failure mode that ruins one run in twenty. - Dungeons are populated at creation, per room, instead of gating on waves. You explore and choose your fights; the run ends when the boss dies, not when the map is swept. Boss rooms have no lock, so walking out is always available. - Aggro: enemies need range AND line of sight, so a dungeon stays quiet until engaged and cover actually protects. - Hard fog, scrolling camera, and terrain rendering. Maps are streamed per peer in chunks around that peer's player, and the seed is deliberately NOT sent -- a client holding it could regenerate the whole dungeon, which is a map hack for free. Stream radius (900u) is wider than view radius (460u) because the client predicts movement against walls and simulates bullets that die on them; the accepted cost is a cheater seeing a little further than the fog, never the floor plan. Partial map knowledge means wall deaths must be announced rather than derived. A test caught the subtle half of that: out-of-bounds tiles read as WALL by design, so checking geometry before bounds reported every bullet leaving the map as a wall kill. 128 tests (was 103); check.sh, test.sh and smoke.sh pass. 0.26 ms/tick with 4 players and a boss, ~65x headroom.
186 lines
5.2 KiB
GDScript
186 lines
5.2 KiB
GDScript
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
|
|
|