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) ## EnemyDef.visual index for the hub's practice target. const VISUAL_DUMMY := 3 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"] if int(e["visual"]) == VISUAL_DUMMY: _draw_target_dummy(e["pos"], r, col) return draw_circle(e["pos"], r, Color(col, 0.35)) draw_arc(e["pos"], r, 0.0, TAU, 24, col, 2.0) ## The hub dummy is a shooting-range target, not an enemy. It used to be drawn ## as a plain grey disc with 100k hit points, which read as scenery -- nothing ## about it said "shoot me" and nothing visibly happened when you did. Concentric ## rings make the intent obvious at a glance. func _draw_target_dummy(pos: Vector2, r: float, col: Color) -> void: draw_circle(pos, r, Color(col, 0.18)) for i in 3: var ring := r * (1.0 - 0.3 * float(i)) draw_arc(pos, ring, 0.0, TAU, 24, Color(col, 0.5 + 0.15 * float(i)), 1.5) draw_circle(pos, r * 0.12, Color(1.0, 0.5, 0.4, 0.9)) 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 flags := int(p["flags"]) var alive := (flags & Protocol.F_ALIVE) != 0 var col := COL_REMOTE if alive else COL_DEAD _draw_ship(p["pos"], p["aim"], col, alive) if (flags & Protocol.F_SPAWN_GRACE) != 0: _draw_grace_ring(p["pos"]) if (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_spawn_grace: _draw_grace_ring(client.predicted_pos) if client.my_escaping: _draw_escape_ring(client.predicted_pos, client.my_escape, COL_LOCAL) ## Arrival protection. Drawn on every protected ship, not just your own, so it ## is clear who can currently be shot and who cannot. func _draw_grace_ring(pos: Vector2) -> void: var pulse := 0.5 + 0.5 * sin(float(Time.get_ticks_msec()) * 0.012) draw_arc(pos, SimConfig.PLAYER_VISUAL_RADIUS + 5.0, 0.0, TAU, 28, Color(0.55, 0.85, 1.0, 0.35 + 0.45 * pulse), 2.0) ## Drawn at PLAYER_VISUAL_RADIUS, larger than the PLAYER_RADIUS hitbox actually ## used for hits -- see the comment on those constants in sim_config.gd. The ## mismatch is deliberate, not a placeholder: a bullet can visibly clip the ## sprite without registering a hit, which reads as more forgiving of latency ## than the reverse. func _draw_ship(pos: Vector2, aim: float, col: Color, alive: bool) -> void: var r := SimConfig.PLAYER_VISUAL_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_VISUAL_RADIUS + 8.0, -PI * 0.5, -PI * 0.5 + TAU * progress, 32, Color(col, 0.9), 3.0)