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,27 @@
|
||||
class_name AimedSpreadEmitter
|
||||
extends BulletEmitter
|
||||
## A fan aimed at the nearest player. This is the emitter that punishes standing
|
||||
## still, so most patterns pair one of these with a geometric emitter.
|
||||
|
||||
@export var count: int = 5
|
||||
@export var spread_deg: float = 24.0
|
||||
## Random angular jitter. Uses the world RNG, which is seeded per instance so a
|
||||
## replay of the same seed produces the same fight.
|
||||
@export var jitter_deg: float = 0.0
|
||||
@export var muzzle_offset: float = 24.0
|
||||
## Extra speed given to the outermost bullets, which curves the fan forward.
|
||||
@export var edge_speed_bonus: float = 0.0
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if count <= 0:
|
||||
return
|
||||
var aim := ctx.aim_angle()
|
||||
var spread := deg_to_rad(spread_deg)
|
||||
for i in count:
|
||||
var t := 0.0 if count == 1 else (float(i) / float(count - 1)) - 0.5
|
||||
var a := aim + spread * t
|
||||
if jitter_deg > 0.0 and ctx.rng != null:
|
||||
a += deg_to_rad(ctx.rng.randf_range(-jitter_deg, jitter_deg))
|
||||
var scale := 1.0 + edge_speed_bonus * absf(t) * 2.0
|
||||
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(aim) * muzzle_offset, scale)
|
||||
@@ -0,0 +1 @@
|
||||
uid://b4njg402onjdw
|
||||
@@ -0,0 +1,31 @@
|
||||
class_name ArcSweepEmitter
|
||||
extends BulletEmitter
|
||||
## A laser-like arm that sweeps back and forth. Dense enough to force movement,
|
||||
## slow enough to read. Pairs well with a ring that fills the space behind it.
|
||||
|
||||
@export var arms: int = 2
|
||||
@export var bullets_per_arm: int = 3
|
||||
## Distance between bullets along one arm.
|
||||
@export var arm_spacing: float = 22.0
|
||||
## Degrees the arm travels per second at the sweep extremes.
|
||||
@export var sweep_deg: float = 70.0
|
||||
## Seconds for one full back-and-forth.
|
||||
@export var sweep_period: float = 4.0
|
||||
@export var base_angle_deg: float = 0.0
|
||||
@export var muzzle_offset: float = 26.0
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if arms <= 0 or bullets_per_arm <= 0:
|
||||
return
|
||||
var t := float(ctx.local_tick) * SimConfig.TICK_DELTA
|
||||
var phase := sin(TAU * t / maxf(sweep_period, 0.001))
|
||||
var base := deg_to_rad(base_angle_deg + sweep_deg * phase)
|
||||
for arm in arms:
|
||||
var a := base + TAU * float(arm) / float(arms)
|
||||
var heading := Vector2.RIGHT.rotated(a)
|
||||
for j in bullets_per_arm:
|
||||
var from := ctx.origin + heading * (muzzle_offset + arm_spacing * float(j))
|
||||
# Outer bullets start further along, so give them matching speed to
|
||||
# keep the arm straight instead of letting it bow.
|
||||
emit_shot(ctx, a, from, 1.0 + 0.08 * float(j))
|
||||
@@ -0,0 +1 @@
|
||||
uid://bxgv7yc1b7rqx
|
||||
@@ -0,0 +1,60 @@
|
||||
@abstract
|
||||
class_name BulletEmitter
|
||||
extends Resource
|
||||
## One firing behaviour on a timeline. A boss phase or an enemy is just a list
|
||||
## of these, which is the whole "format that adapts to other bosses": authoring
|
||||
## a new boss means writing a new list, never new code.
|
||||
|
||||
@export_group("Timeline")
|
||||
## First tick of the owner's phase at which this emitter is armed.
|
||||
@export var start_tick: int = 0
|
||||
## Last armed tick, or -1 to stay armed for the whole phase.
|
||||
@export var end_tick: int = -1
|
||||
## Ticks between shots.
|
||||
@export var interval: int = 30
|
||||
|
||||
@export_group("Bullet")
|
||||
@export var speed: float = 150.0
|
||||
## Units/sec^2 along the heading. Negative values make bullets stall and drift.
|
||||
@export var accel: float = 0.0
|
||||
## Degrees of heading change per tick. Non-zero produces curving bullets.
|
||||
@export var turn_deg: float = 0.0
|
||||
@export var radius: float = 7.0
|
||||
@export var lifetime: int = 300
|
||||
@export var damage: int = 12
|
||||
@export var kind: int = SimConfig.KIND_ORB
|
||||
|
||||
|
||||
func is_armed(local_tick: int) -> bool:
|
||||
if local_tick < start_tick:
|
||||
return false
|
||||
if end_tick >= 0 and local_tick > end_tick:
|
||||
return false
|
||||
return true
|
||||
|
||||
|
||||
func should_fire(local_tick: int) -> bool:
|
||||
if not is_armed(local_tick):
|
||||
return false
|
||||
if interval <= 0:
|
||||
return false
|
||||
return (local_tick - start_tick) % interval == 0
|
||||
|
||||
|
||||
## How many shots this emitter has fired by [param local_tick], used to drive
|
||||
## per-shot spin without the emitter holding mutable state (emitters are shared
|
||||
## resources -- two bosses of the same kind must not stomp each other).
|
||||
func shot_index_at(local_tick: int) -> int:
|
||||
if interval <= 0:
|
||||
return 0
|
||||
return int(floor(float(local_tick - start_tick) / float(interval)))
|
||||
|
||||
|
||||
@abstract func fire(ctx: EmitContext) -> void
|
||||
|
||||
|
||||
## Shared spawn path so every emitter produces bullets with identical physics.
|
||||
func emit_shot(ctx: EmitContext, angle: float, from: Vector2, speed_scale: float = 1.0) -> void:
|
||||
var v := Vector2.RIGHT.rotated(angle) * speed * speed_scale
|
||||
ctx.pool.spawn(from, v, radius, lifetime, damage, SimConfig.TEAM_ENEMY, kind,
|
||||
accel, deg_to_rad(turn_deg))
|
||||
@@ -0,0 +1 @@
|
||||
uid://c27tahmytk5vj
|
||||
@@ -0,0 +1,23 @@
|
||||
class_name EmitContext
|
||||
extends RefCounted
|
||||
## Everything an emitter is allowed to see. Keeping this narrow is what lets a
|
||||
## unit test fire a boss pattern into a bare [BulletPool] with no world at all.
|
||||
|
||||
var pool: BulletPool
|
||||
## Where the shots come from (the emitting actor's position).
|
||||
var origin := Vector2.ZERO
|
||||
## Nearest live player, used by aimed emitters. Falls back to [member origin]
|
||||
## plus a down vector when nobody is alive.
|
||||
var target := Vector2.ZERO
|
||||
var has_target := false
|
||||
## Ticks since the emitting actor entered its current phase.
|
||||
var local_tick := 0
|
||||
## How many times this emitter has fired in this phase. Drives spin/step.
|
||||
var shot_index := 0
|
||||
var rng: RandomNumberGenerator
|
||||
|
||||
|
||||
func aim_angle() -> float:
|
||||
if not has_target:
|
||||
return PI * 0.5
|
||||
return (target - origin).angle()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cav5jlkxsy6qm
|
||||
@@ -0,0 +1,29 @@
|
||||
class_name RingEmitter
|
||||
extends BulletEmitter
|
||||
## Evenly spaced bullets around a full or partial circle. Give it a non-zero
|
||||
## [member spin_per_shot_deg] and consecutive rings become a spiral.
|
||||
|
||||
@export var count: int = 12
|
||||
## Total angle the bullets are spread over. 360 is a closed ring.
|
||||
@export var arc_deg: float = 360.0
|
||||
@export var base_angle_deg: float = 0.0
|
||||
## Added to the base angle on every shot.
|
||||
@export var spin_per_shot_deg: float = 7.0
|
||||
## Spawn distance from the emitter, so big bosses do not fire from their centre.
|
||||
@export var muzzle_offset: float = 24.0
|
||||
## Aim the ring's base angle at the nearest player instead of using world space.
|
||||
@export var aim_at_target: bool = false
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if count <= 0:
|
||||
return
|
||||
var base := deg_to_rad(base_angle_deg + spin_per_shot_deg * float(ctx.shot_index))
|
||||
if aim_at_target:
|
||||
base += ctx.aim_angle()
|
||||
var arc := deg_to_rad(arc_deg)
|
||||
# A closed ring must not place two bullets on top of each other at the seam.
|
||||
var divisor := count if is_equal_approx(arc_deg, 360.0) else maxi(count - 1, 1)
|
||||
for i in count:
|
||||
var a := base + arc * (float(i) / float(divisor))
|
||||
emit_shot(ctx, a, ctx.origin + Vector2.RIGHT.rotated(a) * muzzle_offset)
|
||||
@@ -0,0 +1 @@
|
||||
uid://c5664w2onh7wx
|
||||
@@ -0,0 +1,47 @@
|
||||
class_name WallGapEmitter
|
||||
extends BulletEmitter
|
||||
## A curtain of bullets sweeping across the arena with a survivable gap. The gap
|
||||
## moves by [member gap_step] every shot so players cannot camp one lane.
|
||||
|
||||
## 0 = travels down, 1 = up, 2 = right, 3 = left.
|
||||
@export_enum("Down", "Up", "Right", "Left") var direction: int = 0
|
||||
@export var count: int = 18
|
||||
## Slots left empty. Width 2-3 is tight but fair at default bullet radius.
|
||||
@export var gap_width: int = 3
|
||||
@export var gap_index: int = 6
|
||||
## How far the gap slides each shot. Coprime-ish values feel least predictable.
|
||||
@export var gap_step: int = 5
|
||||
## Randomise the gap instead of stepping it.
|
||||
@export var randomize_gap: bool = false
|
||||
|
||||
|
||||
func _axis() -> Dictionary:
|
||||
match direction:
|
||||
1: return {"dir": Vector2.UP, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
2: return {"dir": Vector2.RIGHT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
3: return {"dir": Vector2.LEFT, "along": Vector2.DOWN, "extent": SimConfig.ARENA_HALF.y, "edge": SimConfig.ARENA_HALF.x}
|
||||
_: return {"dir": Vector2.DOWN, "along": Vector2.RIGHT, "extent": SimConfig.ARENA_HALF.x, "edge": SimConfig.ARENA_HALF.y}
|
||||
|
||||
|
||||
func fire(ctx: EmitContext) -> void:
|
||||
if count <= 0:
|
||||
return
|
||||
var ax := _axis()
|
||||
var dir: Vector2 = ax["dir"]
|
||||
var along: Vector2 = ax["along"]
|
||||
var extent: float = ax["extent"]
|
||||
var edge: float = ax["edge"]
|
||||
|
||||
var gap := gap_index
|
||||
if randomize_gap and ctx.rng != null:
|
||||
gap = ctx.rng.randi_range(0, maxi(count - gap_width, 0))
|
||||
else:
|
||||
gap = posmod(gap_index + gap_step * ctx.shot_index, maxi(count - gap_width + 1, 1))
|
||||
|
||||
var start := -dir * (edge + 8.0)
|
||||
var angle := dir.angle()
|
||||
for i in count:
|
||||
if i >= gap and i < gap + gap_width:
|
||||
continue
|
||||
var t := (float(i) / float(maxi(count - 1, 1))) * 2.0 - 1.0
|
||||
emit_shot(ctx, angle, start + along * (t * extent))
|
||||
@@ -0,0 +1 @@
|
||||
uid://igr031g85v5q
|
||||
Reference in New Issue
Block a user