Initial commit: Transcience MVP
Top-down twin-stick bullet-hell, Godot 4.7, server-authoritative dedicated server with client-side prediction. Clients send input only; the server resolves every hit for both players and enemies (no PvP). - SimWorld: whole simulation as plain RefCounted objects (no nodes, no physics server), ~0.24ms/tick at peak load -- runs headless for free and drives 78 tests in under a second - BulletPool: struct-of-arrays bullet storage, replicated as spawn/despawn events rather than per-tick state - Emitter framework (Ring/AimedSpread/WallGap/ArcSweep) shared by trash enemies and bosses -- a new boss is data in src/content/content.gd, no simulation changes - The Warden of the Fold: stationary 4-phase boss built entirely on that format - Lobby hub with a portal into on-demand dungeon instances; one process hosts the hub plus every concurrent dungeon - Emergency escape: 3s server-owned channel, cancelled by damage - tools/check.sh, test.sh (GUT), smoke.sh (real server + bot clients over ENet), bench.gd; git hooks wired to the same scripts - docs/ARCHITECTURE.md, NETCODE.md, WORKFLOW.md, ROADMAP.md
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
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()
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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()
|
||||
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()
|
||||
|
||||
|
||||
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 or Movement.outside_arena(p):
|
||||
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.outside_arena(pos[slot]):
|
||||
despawn(slot)
|
||||
return
|
||||
Reference in New Issue
Block a user