651c4ad94a
ci / verify (push) Successful in 1m57s
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
120 lines
3.8 KiB
GDScript
120 lines
3.8 KiB
GDScript
extends Node2D
|
|
## Draws everything that is not a bullet. There are only a handful of actors, so
|
|
## immediate-mode [method CanvasItem.draw_circle] is cheaper than keeping nodes
|
|
## in sync with a replicated world.
|
|
##
|
|
## The view is strictly read-only: it never advances the simulation and never
|
|
## decides anything. Every number it draws came from the server.
|
|
|
|
const COL_ARENA := Color(0.22, 0.24, 0.34)
|
|
const COL_LOCAL := Color(0.5, 1.0, 0.8)
|
|
const COL_REMOTE := Color(0.55, 0.75, 1.0)
|
|
const COL_DEAD := Color(0.4, 0.4, 0.45, 0.5)
|
|
const COL_PORTAL := Color(0.5, 0.9, 1.0)
|
|
const ENEMY_COLORS := [
|
|
Color(0.95, 0.55, 0.55), # drifter
|
|
Color(0.85, 0.7, 0.35), # turret
|
|
Color(0.9, 0.4, 0.9), # stalker
|
|
Color(0.5, 0.55, 0.6), # dummy
|
|
]
|
|
|
|
@onready var bullets: MultiMeshInstance2D = $Bullets
|
|
|
|
var client: ClientRuntime = null
|
|
|
|
|
|
func _process(_delta: float) -> void:
|
|
client = Net.client
|
|
if client != null:
|
|
bullets.render_pool(client.world.pool)
|
|
queue_redraw()
|
|
|
|
|
|
func _draw() -> void:
|
|
_draw_arena()
|
|
if client == null:
|
|
return
|
|
if client.instance_kind == Protocol.InstanceKind.LOBBY:
|
|
_draw_portal()
|
|
for e in client.enemies():
|
|
_draw_enemy(e)
|
|
_draw_boss()
|
|
for p in client.remote_players():
|
|
_draw_remote_player(p)
|
|
_draw_local_player()
|
|
|
|
|
|
func _draw_arena() -> void:
|
|
var h := SimConfig.ARENA_HALF
|
|
draw_rect(Rect2(-h, h * 2.0), COL_ARENA, false, 2.0)
|
|
# Faint grid, purely so movement reads against a background.
|
|
var step := 80.0
|
|
var faint := Color(COL_ARENA, 0.25)
|
|
var x := -h.x + step
|
|
while x < h.x:
|
|
draw_line(Vector2(x, -h.y), Vector2(x, h.y), faint, 1.0)
|
|
x += step
|
|
var y := -h.y + step
|
|
while y < h.y:
|
|
draw_line(Vector2(-h.x, y), Vector2(h.x, y), faint, 1.0)
|
|
y += step
|
|
|
|
|
|
func _draw_portal() -> void:
|
|
var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.003)
|
|
draw_arc(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS, 0.0, TAU, 48,
|
|
Color(COL_PORTAL, 0.4 + 0.4 * pulse), 3.0)
|
|
draw_circle(SimConfig.PORTAL_POS, SimConfig.PORTAL_RADIUS * 0.25,
|
|
Color(COL_PORTAL, 0.25 + 0.25 * pulse))
|
|
|
|
|
|
func _draw_enemy(e: Dictionary) -> void:
|
|
var col: Color = ENEMY_COLORS[clampi(int(e["visual"]), 0, ENEMY_COLORS.size() - 1)]
|
|
var r: float = e["radius"]
|
|
draw_circle(e["pos"], r, Color(col, 0.35))
|
|
draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0)
|
|
|
|
|
|
func _draw_boss() -> void:
|
|
var b := client.boss_state()
|
|
if b.is_empty():
|
|
return
|
|
var r: float = client.boss_def.radius if client.boss_def != null else 42.0
|
|
var pos: Vector2 = b["pos"]
|
|
draw_circle(pos, r, Color(0.9, 0.3, 0.4, 0.25))
|
|
draw_arc(pos, r, 0.0, TAU, 48, Color(1.0, 0.4, 0.5), 3.0)
|
|
draw_arc(pos, r + 10.0, 0.0, TAU, 48, Color(1.0, 0.4, 0.5, 0.25), 1.0)
|
|
|
|
|
|
func _draw_remote_player(p: Dictionary) -> void:
|
|
var alive := (int(p["flags"]) & Protocol.F_ALIVE) != 0
|
|
var col := COL_REMOTE if alive else COL_DEAD
|
|
_draw_ship(p["pos"], p["aim"], col, alive)
|
|
if (int(p["flags"]) & Protocol.F_ESCAPING) != 0:
|
|
_draw_escape_ring(p["pos"], float(p["escape"]), col)
|
|
|
|
|
|
func _draw_local_player() -> void:
|
|
if not client.my_alive:
|
|
_draw_ship(client.predicted_pos, client.aim, COL_DEAD, false)
|
|
return
|
|
_draw_ship(client.predicted_pos, client.aim, COL_LOCAL, true)
|
|
if client.my_escaping:
|
|
_draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL)
|
|
|
|
|
|
func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void:
|
|
var r := SimConfig.PLAYER_RADIUS
|
|
draw_circle(pos, r, Color(col, 0.4 if alive else 0.2))
|
|
draw_arc(pos, r, 0.0, TAU, 20, col, 2.0)
|
|
if alive:
|
|
var dir := Vector2.RIGHT.rotated(aim)
|
|
draw_line(pos + dir * r, pos + dir * (r + 12.0), col, 2.0)
|
|
|
|
|
|
## The escape channel is drawn on the player, not just in the HUD, so other
|
|
## players can see someone is about to leave and react.
|
|
func _draw_escape_ring(pos: Vector2, progress: float, col: Color) -> void:
|
|
draw_arc(pos, SimConfig.PLAYER_RADIUS + 8.0, -PI * 0.5,
|
|
-PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)
|