Files
transcience/src/view/world_view.gd
T
Adyrem 651c4ad94a
ci / verify (push) Successful in 1m57s
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
2026-09-03 16:03:57 +02:00

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)