ajhahn.de
← Theria commits

Commit

Theria

feat: in-client error screen with codes for connection failures

ajhahnde · Jun 2026 · d7d59c06dd1de37685beef4dedc1820d75fd9563 · parent: 4c188b8 · view on GitHub →

modified CHANGELOG.md
@@ -30,6 +30,11 @@ protocol version.
- Heroes and units now **walk over the hills** instead of clipping through them — a unit's view
rides the rolling relief, lifted onto each mound's surface as it crosses. The simulation stays
flat (collision and pathing are unchanged); only the rendered view follows the ground.
- A connection that fails now shows an **in-client error screen** — a headline, an error code
(E-1001…E-1005), and what went wrong — with **Back to Menu** and **Quit**, instead of a silent
grey screen or an abrupt quit. Covers a host that cannot open its port, a refused or dropped
connection, and — via a join timeout — a server that never answers, so a join to a down host
reports "could not reach the server" rather than hanging.
### Changed
added src/client/error_codes.gd
@@ -0,0 +1,41 @@
class_name ErrorCode
extends RefCounted
## The client's catalogue of player-facing failure codes. Each failure that would otherwise grey
## the screen or quit without a word gets a stable number, shown on the error screen so a bug
## report can name what broke. The numbers never change once shipped (a player may quote one), so
## a new failure takes the next free number rather than reusing an old one.
##
## Pure data: a code maps to a headline (what went wrong, in plain words). The specific detail —
## which address, which port, the raw reason — is passed alongside the code at the call site, so
## this stays a small fixed table the error overlay (and any future log) can read.
## Could not start hosting — the listen-server socket would not open (usually the port is taken).
const CANT_HOST := 1001
## Could not start the outgoing connection — the address was malformed or the socket would not open.
const CANT_CONNECT := 1002
## The attempt reached no one — no server answered at the address (host down, or wrong address).
const UNREACHABLE := 1003
## The server answered but refused us — today only a protocol-version mismatch (different builds).
const REFUSED := 1004
## The connection dropped after we had joined — the server closed, or the link died mid-match.
const LOST := 1005
const _TITLES := {
CANT_HOST: "Could not host the match",
CANT_CONNECT: "Could not start the connection",
UNREACHABLE: "Could not reach the server",
REFUSED: "The server refused the connection",
LOST: "Lost the connection to the server",
}
## The code as the badge the player sees and quotes — "E-1003". An unknown code still formats, so a
## caller can never crash the error screen by passing a number that is not in the table.
static func label(code: int) -> String:
return "E-%d" % code
## The player-facing headline for a code, or a generic line for an unknown code so the screen always
## has something to say.
static func title(code: int) -> String:
return _TITLES.get(code, "Something went wrong")
added src/client/error_codes.gd.uid
@@ -0,0 +1 @@
uid://cw6tgbghk4ap6
added src/client/error_overlay.gd
@@ -0,0 +1,94 @@
class_name ErrorOverlay
extends Control
## The full-screen error screen, shown when a match fails in a way that would otherwise grey the
## screen or quit without a word — a host that cannot open its port, a join that reaches no server,
## a refused or dropped connection. It names the failure, shows its code (so a bug report can quote
## it), and offers the player a way out: back to the connect menu, or quit. Pure presentation — it
## owns no networking; `main.gd` shows it on a failure and acts on its two signals.
##
## Unlike the death screen (a dim the live world plays on behind), this is an OPAQUE cover: the
## match behind it is broken or gone, so nothing should show through or take a click meant for the
## buttons. A headless run never builds it — there is no screen, and a failed smoke just exits.
## The player chose the connect menu — the driver tears the failed match down and reopens it.
signal menu_requested
## The player chose to quit the game.
signal quit_requested
const TITLE_COLOR := Color(0.90, 0.40, 0.32) # a warm alarm red, distinct from the amber accent
const TITLE_FONT_SIZE := 56
const CODE_FONT_SIZE := 28
const DETAIL_FONT_SIZE := 22
const DETAIL_MAX_WIDTH := 760.0
var _title: Label
var _code: Label
var _detail: Label
func _ready() -> void:
set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
# The shared theme styles the two buttons so the error screen reads as one product with the menu.
theme = UiTheme.make()
# An opaque cover, so the broken match behind it neither shows through nor takes a click — STOP
# (not IGNORE, the way the death dim passes clicks) so the dead world below never catches one.
var backdrop := ColorRect.new()
backdrop.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
backdrop.color = UiTheme.BG
add_child(backdrop)
var center := CenterContainer.new()
center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
add_child(center)
var box := VBoxContainer.new()
box.alignment = BoxContainer.ALIGNMENT_CENTER
box.add_theme_constant_override("separation", 22)
center.add_child(box)
_title = _line(TITLE_FONT_SIZE, TITLE_COLOR)
box.add_child(_title)
_code = _line(CODE_FONT_SIZE, UiTheme.ACCENT)
box.add_child(_code)
_detail = _line(DETAIL_FONT_SIZE, UiTheme.TEXT_MUTED)
_detail.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_detail.custom_minimum_size = Vector2(DETAIL_MAX_WIDTH, 0.0)
box.add_child(_detail)
var row := HBoxContainer.new()
row.alignment = BoxContainer.ALIGNMENT_CENTER
row.add_theme_constant_override("separation", 18)
box.add_child(row)
var menu_button := Button.new()
menu_button.text = "Back to Menu"
menu_button.pressed.connect(func() -> void: menu_requested.emit())
row.add_child(menu_button)
var quit_button := Button.new()
quit_button.text = "Quit"
quit_button.pressed.connect(func() -> void: quit_requested.emit())
row.add_child(quit_button)
hide()
## A centred label at `size` in `color` — the one label shape the screen reuses for its three lines.
func _line(size: int, color: Color) -> Label:
var label := Label.new()
label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
label.add_theme_font_size_override("font_size", size)
label.add_theme_color_override("font_color", color)
return label
## Fills the screen for `code` — its headline and badge — and `detail` (the specific what/where),
## then raises it. The driver halts the failed match before calling this, so the screen sits still.
func show_error(code: int, detail: String) -> void:
_title.text = ErrorCode.title(code)
_code.text = "Error %s" % ErrorCode.label(code)
_detail.text = detail
show()
added src/client/error_overlay.gd.uid
@@ -0,0 +1 @@
uid://ck6g7hu6ny47x
modified src/client/main.gd
@@ -1,32 +1,16 @@
extends Node3D
## Presentation + driver for the v0.1 match. It runs in one of three modes. A
## windowed launch with no mode flag opens a connect menu to pick one; the command
## line selects one directly (`-- --host`, `-- --join [address]`, `-- --local`); and
## a headless launch with no flag defaults to LOCAL, so the automated smokes need no
## menu and stay flag-driven:
##
## LOCAL — owns the authoritative SimCore and fields a full Solane squad per
## team: the player drives one hero (picked with `--hero`), every other
## seat is bot-driven. The single-machine practice match.
## HOST — the listen-server: owns the authoritative SimCore, drives team 0 from
## local input, hands team 1 to a remote client when one connects (a bot
## until then), and broadcasts a snapshot every tick.
## CLIENT — owns no authority: it samples local input, sends it up, and draws the
## server's snapshots — but predicts its own hero locally from that input so it
## responds without a round-trip, reconciling against every snapshot. Remote
## entities render a short delay in the past, interpolated between buffered
## snapshots, so they move smoothly through jitter and dropped packets. A
## `--netsim <latency>,<jitter>,<loss>` shapes the incoming stream to debug this.
##
## Authority stays in SimCore; transport in NetSession; wire shaping in NetProtocol;
## remote-entity smoothing in SnapshotInterpolator. This node samples input, routes it,
## predicts the client's own hero, interpolates the rest, and presents the result.
##
## Presentation is 2.5D: the sim stays a flat 2D world (`Vector2`) and the client renders it
## under a pitched `Camera3D` following the hero — a sim point `Vector2(x, y)` maps to
## `Vector3(x, 0, y)` on the ground. Every entity owns a pooled 3D view (mesh + billboarded
## bars + status label) reconciled against the live state each tick. The wire is untouched —
## a pure presentation layer over the same 2D state every mode produces.
## Presentation + driver for the v0.1 match in three modes (no flag → connect menu; `-- --host` /
## `-- --join [address]` / `-- --local` select directly; headless defaults to LOCAL):
## LOCAL — owns the authoritative SimCore; full squad per team, player drives one hero
## (`--hero`) and bots the rest. The single-machine practice match.
## HOST — listen-server: owns the SimCore, drives team 0, hands team 1 to a client on connect
## (a bot until then), broadcasts a snapshot per tick.
## CLIENT — no authority: samples input, sends it up, draws snapshots, but predicts its own hero
## (reconciling each snapshot) and interpolates remote entities a delay in the past.
## `--netsim <l>,<j>,<loss>` shapes intake.
## Authority in SimCore; transport NetSession; wire NetProtocol; smoothing SnapshotInterpolator.
## Presentation is 2.5D: a flat 2D sim (`Vector2`) under a pitched `Camera3D`, `Vector2(x, y)` →
## `Vector3(x, 0, y)`; each entity owns a pooled 3D view reconciled each tick. Wire untouched.
enum Mode { LOCAL, HOST, CLIENT }
@@ -37,49 +21,50 @@ const BOT_TEAM := 1
const DEFAULT_JOIN_ADDRESS := "127.0.0.1"
## Fixed seed for the optional `--netsim` conditioner, so a shaped playtest replays
## the same drop and jitter pattern run to run.
## How long a join may sit unanswered before the error screen calls it unreachable. ENet's own
## `connection_failed` does not fire for a dead *localhost* port (UDP, no refusal), so without this
## backstop a join to a down host hangs forever on a static map.
const JOIN_TIMEOUT_MS := 6000
## SceneTree meta set by "Back to Menu" before a reload: it outlives the reload, so the reborn
## client opens the connect menu even when it was launched straight into a mode. Cleared on read.
const FORCE_MENU_META := "theria_force_menu"
## Fixed seed for the `--netsim` conditioner, so a shaped playtest replays the same drops/jitter.
const NETSIM_SEED := 1
# --- Presentation (2.5D) ----------------------------------------------------
# The sim is a flat 2D world rendered under a pitched Camera3D, Vector2(x, y) at
# Vector3(x, 0, y). Sizes are world units, 1:1 with the sim so the mouse-ray needs no rescale.
# Flat 2D world under a pitched Camera3D, Vector2(x, y) at Vector3(x, 0, y); units 1:1, no rescale.
const HERO_COLOR := Color(0.36, 0.66, 1.0)
const BOT_COLOR := Color(1.0, 0.42, 0.38)
## Hero body: a standing capsule of this radius and height. CREEP_* is the smaller body a
## wave member gets, so a wave reads as a cluster apart from the heroes.
## Hero body: a standing capsule (radius/height). CREEP_* is the smaller wave-member body.
const ENTITY_RADIUS := 44.0
const HERO_BODY_HEIGHT := 150.0
const CREEP_RADIUS := 31.0
const CREEP_BODY_HEIGHT := 80.0
const CREEP_DARKEN := 0.3
## Per-hero tint: a team's heroes share its base colour, each shaded by its roster seat (0..2)
## so squadmates read apart while the team hue stays. Indexed by `AbilityData.roster_index`;
## positive lightens, negative darkens; no seat (unknown kit) keeps the flat team colour.
## Per-hero tint by roster seat (0..2) so squadmates read apart while the team hue stays. Indexed
## by `AbilityData.roster_index`; +lightens, -darkens; no seat (unknown kit) keeps flat colour.
const HERO_SHADES: Array[float] = [0.0, 0.28, -0.22]
## Structures stand as boxes on the ground: a square footprint (tower/nexus) extruded
## up by STRUCTURE_HEIGHT.
## Structures are boxes: a square footprint (tower/nexus) extruded up by STRUCTURE_HEIGHT.
const TOWER_SIZE := 110.0
const NEXUS_SIZE := 200.0
const STRUCTURE_HEIGHT := 220.0
## Ground + lighting. The ground plane wears a jungle short-grass shader (GROUND_SHADER —
## toon-banded patches of two greens); behind it the sky is a dark jungle backdrop. The key
## light and ambient fill are tuned so the cel-banded units and ground read with depth
## rather than as flat dots.
## Ground + lighting. The plane wears a jungle short-grass shader (GROUND_SHADER — toon-banded
## two greens) over a dark backdrop; key light + ambient fill give the cel-banded units depth.
const GROUND_SHADER: Shader = preload("res://src/client/ground.gdshader")
const BACKDROP_COLOR := Color(0.06, 0.12, 0.09)
const AMBIENT_COLOR := Color(0.52, 0.56, 0.64)
const AMBIENT_ENERGY := 0.5
const LIGHT_ENERGY := 1.1
## Billboarded HP/resource bars + status label floating above a unit (world units). Every
## body's HP bar floats HERO_BAR_GAP above its own model's measured top (animals, creeps, and
## structures all vary in height), the resource bar a step below and the status label above.
## Billboarded HP/resource bars + status label above a unit (world units). HP bar floats
## HERO_BAR_GAP above the model's measured top (heights vary); resource bar below, label above.
const BAR_WIDTH := 170.0
const BAR_HEIGHT := 24.0
const HERO_BAR_GAP := 70.0
@@ -90,18 +75,15 @@ const HP_BAR_FG := Color(0.4, 0.85, 0.4)
const RES_BAR_FG := Color(0.35, 0.6, 0.95)
const STATUS_FONT_SIZE := 120
## The tribe the player's team falls back to in a LOCAL practice match when `--hero` names no
## known hero. Rosters live in `AbilityData.TRIBE`; `_start_local` seats the chosen tribe
## against the opposing one. HOST/CLIENT still seat the one-per-team duel (DUEL_KIT below)
## until the protocol step that gives each client a controlled-entity id lands.
## LOCAL fallback tribe when `--hero` names no known hero. Rosters in `AbilityData.TRIBE`;
## `_start_local` seats the chosen tribe vs the opposing one. HOST/CLIENT seat the one-per-team
## duel (DUEL_KIT) until the protocol step granting each client a controlled-entity id lands.
const DEFAULT_TRIBE := "solane"
## The kit both heroes mirror in a HOST/CLIENT duel — the one-per-team walking
## skeleton the netcode is built around until the multi-hero wire step lands.
## Kit both heroes mirror in a HOST/CLIENT duel — the one-per-team skeleton until multi-hero wire.
const DUEL_KIT := "lion"
## Form ring laid flat on the ground under a hero, reading its active shapeshifter
## form — white while human, amber while shifted to the animal form.
## Form ring under a hero: white while human, amber while shifted to the animal form.
const FORM_RING_RADIUS := 70.0
const FORM_RING_THICKNESS := 12.0
const HUMAN_RING_COLOR := Color(0.95, 0.95, 0.95)
@@ -110,46 +92,35 @@ const ANIMAL_RING_COLOR := Color(1.0, 0.62, 0.2)
var _mode: int = Mode.LOCAL
var _join_address := DEFAULT_JOIN_ADDRESS
## True once a mode flag (`--host`/`--join`/`--local`) was passed, so a flagged or
## headless launch enters the match directly and a bare windowed launch shows the menu.
## True once a mode flag was passed: flagged/headless launches enter directly, bare windowed → menu.
var _explicit_mode := false
## The connect-menu overlay while it is up; freed once a mode is chosen. Null on a
## flagged or headless launch (the menu never opens) and after the match begins.
## Connect-menu overlay while up; freed once a mode is chosen. Null on flag/headless and post-start.
var _menu_layer: CanvasLayer = null
## False until a mode has started; gates the per-tick driver and entity draw so the
## menu can sit over a static backdrop with no simulation running behind it.
## False until a mode starts; gates the per-tick driver and draw so the menu sits over a static map.
var _started := false
## CLIENT: optional simulated link conditions parsed from `--netsim
## <latency>,<jitter>,<loss>`, as `[latency_ms, jitter_ms, loss]`, or empty to take
## snapshots as they arrive. A debug aid for exercising the smoothing under a worse
## link than the local machine provides.
## CLIENT: simulated link from `--netsim <latency>,<jitter>,<loss>` as `[latency_ms, jitter_ms,
## loss]`, or empty to take snapshots as they arrive. Debug aid for smoothing on a worse link.
var _netsim_params: Array = []
## The authoritative simulation. Present in LOCAL and HOST; null on a pure CLIENT,
## which renders snapshots instead of simulating.
## The authoritative simulation. Present in LOCAL/HOST; null on a pure CLIENT (renders snapshots).
var _sim: SimCore = null
var _bot := BotController.new()
var _hero_id: int = 0
var _bot_id: int = 0
## Samples the local player's mouse/keys into an InputCommand and owns the move/attack order
## state (right-click to move or attack, QWER to cast). Built once the camera exists.
## Samples mouse/keys into an InputCommand; owns move/attack order state. Built once camera exists.
var _player_input: PlayerInput = null
## The on-ground marker drawn at the active move target while the hero walks to it.
var _move_marker: MoveMarker = null
## LOCAL: the hero the player drives, from `--hero` — any hero of either tribe. Its
## tribe fills the player's team and the opposing tribe the bot team, so the choice also
## picks the match-up. Falls back to the first hero of the default tribe if unset or
## unrecognised. Ignored by HOST/CLIENT, which seat the duel.
## LOCAL: hero the player drives (`--hero`, any tribe); its tribe fields the player's team, the
## opposing one the bots — picks the match-up. Default tribe's first hero if unknown; net ignores.
var _player_hero: String = AbilityData.TRIBE[DEFAULT_TRIBE][0]
## The bot skill level, from `--bot-difficulty` or the menu, applied to `_bot` when the
## match begins. Defaults to "easy" so practice is winnable out of the box; "normal" and
## "hard" sharpen the bots' reaction. Held as a name and resolved to a level at apply.
## Bot skill from `--bot-difficulty` or the menu, applied to `_bot` at match start. Defaults to
## "easy" (winnable); "normal"/"hard" sharpen reaction. Held as a name, resolved at apply.
var _bot_difficulty: String = "easy"
## LOCAL: every bot-driven hero this match — the player's two squadmates and the
## three opponents — each stepped from its own BotController decision.
## LOCAL: every bot-driven hero (two squadmates + three opponents), each stepped by BotController.
var _bot_ids: Array[int] = []
var _net: NetSession = null
@@ -157,46 +128,41 @@ var _net: NetSession = null
var _team1_peer: int = 0
## CLIENT: set once the server has accepted our handshake.
var _joined: bool = false
## CLIENT: tick-time deadline by which the join must complete, else the error screen calls it
## unreachable. Set when the connection starts; ignored once `_joined`.
var _join_deadline_ms: int = 0
## CLIENT: the team the server assigned us; identifies our hero in a snapshot.
var _my_team: int = BOT_TEAM
## CLIENT: the world to draw — remote entities interpolated a short delay in the
## past, with our own hero overlaid at its predicted (present) position.
## CLIENT: world to draw — remote entities interpolated in the past, own hero overlaid at present.
var _client_state: SimState = null
## CLIENT: buffers recent snapshots and renders remote entities interpolated
## between them, smoothing network jitter and dropped packets.
## CLIENT: buffers recent snapshots, interpolating remote entities to smooth jitter and drops.
var _interp := SnapshotInterpolator.new()
## CLIENT: monotonic input sequence number, stamped on each input we send so the
## server can acknowledge it and we can match the ack back to a pending input.
## CLIENT: monotonic input seq stamped on each input, so the server's ack matches a pending input.
var _input_seq: int = 0
## CLIENT: inputs sent but not yet acknowledged, oldest first, each `{seq, input}`.
## Replayed onto every snapshot to predict our hero; pruned as acks arrive.
## CLIENT: unacked inputs (oldest first, `{seq, input}`), replayed onto each snapshot; acks prune.
var _pending_inputs: Array[Dictionary] = []
## Presentation: the follow-camera, the ground plane, and the per-entity view pool.
## Each view holds the node refs `_update_view` mutates — `{root, body, ring?, hp_node,
## hp_fg, res_node?, res_fg?, status?}` — so a unit's nodes are built once, never rebuilt
## while it lives. Filled in `_build_world` / `_sync_world`; see the presentation region.
## The follow-rig — the Camera3D, its eased target, and the free-look state — lifted into its own
## class to keep this file under the line cap. Built in `_build_world`, trailed each tick.
## Presentation: the follow-camera, ground plane, and per-entity view pool. Each view holds the
## node refs `_update_view` mutates — `{root, body, ring?, hp_node, hp_fg, res_node?, res_fg?,
## status?}` — built once per unit, never rebuilt. Filled in `_build_world`/`_sync_world`.
## The follow-rig (Camera3D, eased target, free-look) is its own class to stay under the line cap.
var _cam: MatchCamera = null
var _ground: MeshInstance3D = null
## The shared map-decor material (JungleDecor); fed the hero's world position each frame so the
## growth over the player's hero fades and the character stays visible.
## Shared map-decor material (JungleDecor); fed the hero's position so growth over it fades.
var _foliage_mat: ShaderMaterial = null
var _views: Dictionary = {}
## The match's screen-space UI — the hero HUD, the kill feed, the chat box, and the death
## screen — built and driven as one layer by `MatchOverlays`, reconciled each tick in
## `_sync_world`. Null on a headless run (no display to draw it on).
## Screen-space UI (HUD, kill feed, chat, death screen) built and driven as one layer by
## `MatchOverlays`, reconciled each tick in `_sync_world`. Null on a headless run.
var _overlays: MatchOverlays = null
## The fog-of-war sheet over the playfield, fed the player team's reveal circles each tick in
## `_sync_world`. Null on a headless run (no display to draw it on).
## Fog-of-war sheet, fed the player team's reveal circles each tick in `_sync_world`. Null headless.
var _fog: FogOverlay = null
func _ready() -> void:
_build_world()
_configure_from_cmdline()
if _explicit_mode or _is_headless():
# "Back to Menu" reload lands on the menu; else a flag/headless run enters directly, bare → menu.
if not _forced_to_menu() and (_explicit_mode or _is_headless()):
_enter_match()
else:
_open_connect_menu()
@@ -250,10 +216,8 @@ func _configure_from_cmdline() -> void:
i += 1
## Parses a `--netsim` value of `latency,jitter,loss` (milliseconds, milliseconds,
## a 0..1 fraction) into `[latency_ms, jitter_ms, loss]`. Missing trailing fields
## default to zero; a malformed value yields an empty array (the conditioner is left
## off) with a warning, so a typo degrades to a normal join rather than a crash.
## Parses `--netsim` `latency,jitter,loss` (ms, ms, 0..1) into `[latency_ms, jitter_ms, loss]`.
## Missing fields default to 0; malformed yields `[]` (conditioner off) with a warning, not a crash.
func _parse_netsim(value: String) -> Array:
var fields := value.split(",")
var nums: Array = []
@@ -269,9 +233,8 @@ func _parse_netsim(value: String) -> Array:
]
## Records the bot skill level from a `--bot-difficulty` value (or the menu), keeping it
## only when it names a known level so a typo degrades to the current default with a
## warning rather than starting an unintended difficulty.
## Records bot skill from `--bot-difficulty` (or the menu), kept only when it names a known level
## so a typo degrades to the current default with a warning, not an unintended difficulty.
func _set_bot_difficulty(level_name: String) -> void:
if BotController.DIFFICULTY_NAMES.has(level_name):
_bot_difficulty = level_name
@@ -281,31 +244,38 @@ func _set_bot_difficulty(level_name: String) -> void:
])
## Dispatches to the selected mode and marks the match live, so the per-tick driver
## and entity draw begin. The single entry point for both the command-line path and a
## menu choice.
## Dispatches to the selected mode and marks the match live (starting the per-tick driver and
## draw). Single entry point for both the command-line path and a menu choice.
func _enter_match() -> void:
_bot.difficulty = BotController.difficulty_from_name(_bot_difficulty)
var ok := true
match _mode:
Mode.HOST:
_start_host()
ok = _start_host()
Mode.CLIENT:
_start_client()
ok = _start_client()
_:
_start_local()
_started = true
# A failed net start already raised the error screen; leave the driver stopped. LOCAL always runs.
_started = ok
## A headless run cannot drive a menu (no display, no pointer), so it always takes a
## mode from the command line — defaulting to LOCAL — and never opens the connect
## screen. This keeps the automated smokes flag-driven and non-interactive.
## A headless run cannot drive a menu (no display/pointer), so it takes a mode from the command
## line (default LOCAL) and never opens the connect screen — keeping smokes flag-driven.
func _is_headless() -> bool:
return DisplayServer.get_name() == "headless"
## Opens the connect menu over a static map backdrop and waits: the match begins only
## once the player picks a mode. Built in code on its own CanvasLayer so it renders in
## screen space, above the world the zoomed game camera draws.
## Whether this start follows a "Back to Menu" reload (SceneTree meta survives it). Cleared on read.
func _forced_to_menu() -> bool:
if not get_tree().has_meta(FORCE_MENU_META):
return false
get_tree().remove_meta(FORCE_MENU_META)
return true
## Opens the connect menu over a static backdrop; the match begins only once a mode is picked.
## Built in code on its own CanvasLayer so it renders in screen space over the world.
func _open_connect_menu() -> void:
var menu := ConnectMenu.new()
menu.default_address = DEFAULT_JOIN_ADDRESS
@@ -319,10 +289,8 @@ func _open_connect_menu() -> void:
add_child(_menu_layer)
## The menu's Practice choice carries the hero the player picked and the bot difficulty;
## both override any `--hero` / `--bot-difficulty` parsed from the command line. The hero's
## tribe fields the player's team and the opposing tribe the bots, so the pick also chooses
## the match-up.
## Practice carries the picked hero and bot difficulty, both overriding any `--hero`/
## `--bot-difficulty`. The hero's tribe fields the player's team, the opposing one the bots.
func _on_practice_requested(hero: String, difficulty: String) -> void:
_mode = Mode.LOCAL
_player_hero = hero
@@ -341,8 +309,7 @@ func _on_join_requested(address: String) -> void:
_close_menu_and_enter()
## Tears down the connect overlay and enters the chosen match. Shared by every menu
## choice so the menu always leaves the tree exactly once, before the match runs.
## Tears down the connect overlay and enters the chosen match. Shared by every menu choice.
func _close_menu_and_enter() -> void:
if _menu_layer != null:
_menu_layer.queue_free()
@@ -350,11 +317,24 @@ func _close_menu_and_enter() -> void:
_enter_match()
## Practice: a tribe-vs-tribe match. `--hero` names the hero the player drives; that
## hero's tribe (per `AbilityData.TRIBE`) fills the player's team and the opposing tribe the
## bot team, one hero per roster kit. The player drives the matching seat; the other five
## are bot-driven, so both rosters are on the field at once. An unknown name falls back
## to the default tribe's first hero, so a typo starts a valid match instead of crashing.
## Error screen's "Back to Menu": reload the scene and open the menu on the fresh start, so the
## player can pick again without relaunching. A full reload is the simplest correct reset (every
## per-match node/field defaults). The forced-menu flag rides the SceneTree (outlives the reload).
func _return_to_menu() -> void:
if _net != null:
_net.close() # drop the ENet peer before the reload frees its session, so it never lingers
get_tree().set_meta(FORCE_MENU_META, true)
get_tree().reload_current_scene()
## The error screen's "Quit".
func _quit_game() -> void:
get_tree().quit()
## Practice: tribe-vs-tribe. `--hero` names the player's hero; its tribe (`AbilityData.TRIBE`)
## fills the player's team, the opposing tribe the bots, one hero per kit. Player drives the
## matching seat, the other five are bots. Unknown name → default tribe's first hero (no crash).
func _start_local() -> void:
_sim = _new_world()
var player_tribe := AbilityData.tribe_of(_player_hero)
@@ -371,10 +351,8 @@ func _start_local() -> void:
_seat_squad(BOT_TEAM, BOT_SPEED, bot_roster, -1)
## Seats one hero per kit in `roster` for `team`, each fanned across the base fountain
## and equipped with its kit. The seat at `player_slot` becomes the player's hero
## (`_hero_id`); every other seat is bot-driven (appended to `_bot_ids`). A
## `player_slot` of -1 leaves the whole team bot-driven.
## Seats one hero per kit in `roster` for `team`, fanned across the base fountain and equipped.
## Seat `player_slot` becomes `_hero_id`; others are bot-driven (`_bot_ids`). -1 → all bots.
func _seat_squad(team: int, speed: float, roster: Array[String], player_slot: int) -> void:
for i in roster.size():
var id := _sim.add_hero(team, MapData.squad_spawn(team, i, roster.size()), speed)
@@ -385,49 +363,52 @@ func _seat_squad(team: int, speed: float, roster: Array[String], player_slot: in
_bot_ids.append(id)
## The HOST/CLIENT walking skeleton: exactly one hero per team, both mirroring the
## duel kit. The wire identifies a hero by its team, so this one-per-team seating is
## what the netcode — prediction, interpolation, snapshot identity — is built
## around; the LOCAL squad stays off the wire until that protocol step lands.
## HOST/CLIENT skeleton: one hero per team, both mirroring the duel kit. The wire IDs a hero by
## team, so one-per-team is what the netcode is built around; the LOCAL squad stays off the wire.
func _seat_duel() -> void:
_sim = _new_world()
_hero_id = _sim.add_hero(HERO_TEAM, MapData.spawn_for_team(HERO_TEAM), HERO_SPEED)
_bot_id = _sim.add_hero(BOT_TEAM, MapData.spawn_for_team(BOT_TEAM), BOT_SPEED)
# Both heroes carry the kit so the match starts mirror-fair; the bot drives
# movement only (no casts yet) but shows its form and resource.
# Both carry the kit (mirror-fair); the bot drives movement only but shows its form and resource.
_sim.equip_kit(_hero_id, DUEL_KIT)
_sim.equip_kit(_bot_id, DUEL_KIT)
## A fresh authoritative world with its structures spawned, shared by both the
## LOCAL squad and the HOST/CLIENT duel seating.
## A fresh authoritative world with structures spawned; shared by LOCAL squad and duel seating.
func _new_world() -> SimCore:
var sim := SimCore.new()
sim.spawn_structures()
return sim
func _start_host() -> void:
func _start_host() -> bool:
_seat_duel() # the authoritative world; team 1 is bot-filled until a client takes it
_net = _make_session()
var err := _net.start_host()
if err != OK:
push_error("failed to host on port %d: error %d" % [NetSession.DEFAULT_PORT, err])
return
var detail := "Port %d would not open (error %d) — it may already be in use." % [
NetSession.DEFAULT_PORT, err
]
_fail(ErrorCode.CANT_HOST, detail)
return false
_net.client_joined.connect(_on_client_joined)
_net.client_left.connect(_on_client_left)
print("hosting on port %d — team 0 is local, team 1 awaits a client" % NetSession.DEFAULT_PORT)
return true
func _start_client() -> void:
func _start_client() -> bool:
_net = _make_session()
var err := _net.start_client(_join_address)
if err != OK:
push_error("failed to join %s: error %d" % [_join_address, err])
return
var detail := "Could not open a connection to %s (error %d)." % [_join_address, err]
_fail(ErrorCode.CANT_CONNECT, detail)
return false
_net.joined_server.connect(_on_joined_server)
_net.rejected.connect(_on_rejected)
_net.connect_failed.connect(_on_connect_failed)
_net.server_left.connect(_on_server_left)
_join_deadline_ms = Time.get_ticks_msec() + JOIN_TIMEOUT_MS
if not _netsim_params.is_empty():
_net.configure_netsim(_netsim_params[0], _netsim_params[1], _netsim_params[2], NETSIM_SEED)
print(
@@ -435,6 +416,7 @@ func _start_client() -> void:
% [_netsim_params[0], _netsim_params[1], roundi(_netsim_params[2] * 100.0)]
)
print("joining %s:%d" % [_join_address, NetSession.DEFAULT_PORT])
return true
func _make_session() -> NetSession:
@@ -464,16 +446,21 @@ func _tick_host() -> void:
else:
team1_command = _bot.decide(_sim.state, _bot_id)
_sim.step({_hero_id: _sample_player_input(), _bot_id: team1_command})
# Fog of war: the client only ever receives what its team (the remote team) can see, so an
# enemy in fog never crosses the wire — the filter is authoritative, not a render dim.
# Fog of war: the client only receives what its (remote) team sees — authoritative filter, not dim.
_net.broadcast_snapshot(_sim.state, ack, Vision.visible_ids(_sim.state, NetSession.REMOTE_TEAM))
## Samples local input, sends it up stamped with a sequence number, buffers it as pending,
## feeds the latest snapshot to the interpolator, then rebuilds the world to draw. Prediction
## makes the local hero respond without a round-trip; interpolation smooths the rest.
## Samples input, sends it up with a seq number, buffers it pending, feeds the latest snapshot to
## the interpolator, rebuilds the world. Prediction skips the round-trip; interp smooths the rest.
func _tick_client() -> void:
if _joined:
if not _joined:
if Time.get_ticks_msec() > _join_deadline_ms:
var detail := "No server answered at %s:%d. Check the address, or that a host is running." % [
_join_address, NetSession.DEFAULT_PORT
]
_fail(ErrorCode.UNREACHABLE, detail)
return
else:
_input_seq += 1
var command := _sample_player_input()
_net.send_input(_input_seq, command)
@@ -482,11 +469,9 @@ func _tick_client() -> void:
_client_state = _render_state()
## Feeds freshly arrived authoritative snapshots into the interpolation buffer. With a
## `--netsim` conditioner the session releases snapshots whose simulated delay has elapsed,
## stamped with their release time so injected latency/jitter read as real arrival timing;
## otherwise the freshest is buffered as it stands. The interpolator ignores ticks it already
## holds, so each distinct snapshot is buffered once, from its own decoded copy.
## Feeds arrived authoritative snapshots into the interpolation buffer. A `--netsim` conditioner
## releases snapshots once their simulated delay elapsed, stamped with release time so injected
## latency/jitter reads as real arrival; else the freshest as-is. Deduped, so each is buffered once.
func _buffer_snapshots() -> void:
var now := float(Time.get_ticks_msec())
if _net.is_conditioned():
@@ -498,10 +483,9 @@ func _buffer_snapshots() -> void:
_interp.push(state, now)
## The world to draw: remote entities interpolated in the past (smoothing jitter, absorbing
## dropped snapshots, delay adapting to the live link), with our own hero overlaid at its
## predicted present-time position. Both halves derive only from the server's snapshots —
## authority is never forked. Null until the first snapshot arrives.
## World to draw: remote entities interpolated in the past (smoothing jitter/drops, delay adapts to
## the link), with our own hero overlaid at its predicted present. Both halves derive only from the
## server's snapshots — authority is never forked. Null until the first snapshot.
func _render_state() -> SimState:
var state := _interp.sample(Time.get_ticks_msec() - _interp.target_delay_ms())
if state == null:
@@ -510,8 +494,7 @@ func _render_state() -> SimState:
return state
## Replaces our hero's interpolated (past) position in `state` with its predicted present-time
## position, so only our hero escapes the interpolation delay while everything else stays smoothed.
## Swaps our hero's interpolated `state` position for its predicted present, escaping the delay.
func _overlay_predicted_hero(state: SimState) -> void:
var predicted := _predicted_hero()
if predicted == null:
@@ -521,10 +504,9 @@ func _overlay_predicted_hero(state: SimState) -> void:
hero.position = predicted.position
## Our hero reconciled against the latest snapshot: take its authoritative position, drop the
## inputs the server has already applied, and replay the rest with the server's movement math.
## The snapshot rolls our hero back to the server's truth before the replay, so a misprediction
## self-corrects within a tick. Null before the first snapshot or if our hero is not in it.
## Our hero reconciled against the latest snapshot: take its authoritative position, drop inputs the
## server already applied, replay the rest with the server's movement math. The rollback to server
## truth before replay self-corrects a misprediction within a tick. Null before first / if absent.
func _predicted_hero() -> SimEntity:
var state := _net.latest_state()
if state == null:
@@ -570,27 +552,50 @@ func _on_joined_server(team: int) -> void:
func _on_rejected(reason: String) -> void:
push_error("the server refused the connection: %s" % reason)
get_tree().quit()
_fail(ErrorCode.REFUSED, _reason_text(reason))
## Connection timed out, no server answering (host down/wrong address). ENet fires this; else hangs.
func _on_connect_failed() -> void:
_fail(
ErrorCode.UNREACHABLE,
"No server answered at %s:%d. Check the address, or that a host is running." %
[_join_address, NetSession.DEFAULT_PORT]
)
func _on_server_left() -> void:
push_error("lost the connection to the server")
get_tree().quit()
_fail(ErrorCode.LOST, "The match server is no longer reachable.")
## A handshake refusal reason, turned into a player-facing line. Today the only reason is a
## protocol-version mismatch (the builds differ); an unknown reason still shows, quoting the tag.
func _reason_text(reason: String) -> String:
if reason == "protocol_version":
return "The server is running a different version of the game."
return "The server refused the connection (%s)." % reason
## Halts the match and raises the error screen (code + detail). Headless has no screen, so it exits.
func _fail(code: int, detail: String) -> void:
push_error("%s [%s]: %s" % [ErrorCode.title(code), ErrorCode.label(code), detail])
_started = false
if _overlays != null:
_overlays.error.show_error(code, detail)
else:
get_tree().quit()
# --- Rendering --------------------------------------------------------------
## The world this client should draw: the predicted + interpolated render state on a
## pure CLIENT, the authoritative simulation otherwise.
## World to draw: the predicted + interpolated render state on a CLIENT, else the authoritative sim.
func _active_state() -> SimState:
return _client_state if _mode == Mode.CLIENT else _sim.state
# --- Presentation: 3D world + view pool -------------------------------------
# A sim point on the 2D field, Vector2(x, y), sits at Vector3(x, 0, y) on the ground.
# Each entity owns a pooled view (`_views[id]`), reconciled against the live state.
# Sim point Vector2(x, y) sits at Vector3(x, 0, y); each entity owns a pooled view (`_views[id]`).
## A sim point on the 2D field, placed on the 3D ground: Vector2(x, y) -> (x, 0, y).
@@ -598,18 +603,14 @@ func _world(p: Vector2) -> Vector3:
return Vector3(p.x, 0.0, p.y)
## A sim point placed on the rolling terrain: the flat-ground point lifted by the hill height under
## it, so a unit's view walks over a mound instead of clipping through it. The sim stays flat — its
## collision and pathing are 2D on Y = 0; only the rendered node rides the relief. Used for unit
## roots — the camera, ground plane, marker, and canopy fade stay flat.
## A sim point on the rolling terrain: the flat point lifted by the hill height under it, so a view
## walks over a mound. Sim stays flat (2D collision/pathing on Y=0); only unit roots ride relief.
func _ground_at(p: Vector2) -> Vector3:
return _world(p) + Vector3(0.0, JungleDecor.height_at(p), 0.0)
## Builds the static 3D scene once: a ground plane spanning the arena, a key light and
## an ambient fill so the primitives read with depth, and the follow-camera framing the
## arena centre to start. Authored in code (not the .tscn) so the scene file stays a
## bare root and the Godot editor — which rewrites project.godot — is never needed.
## Builds the static 3D scene once: ground plane, key light + ambient fill for depth, follow-camera
## framing the arena centre. Authored in code (not .tscn) so the editor is never needed.
func _build_world() -> void:
var env := Environment.new()
env.background_mode = Environment.BG_COLOR
@@ -639,22 +640,22 @@ func _build_world() -> void:
_player_input = PlayerInput.new(_cam.node)
_move_marker = MoveMarker.new()
add_child(_move_marker)
# The screen-space UI (HUD, kill feed, chat, death screen) draws over the zoomed game camera,
# exactly like the connect menu. MatchOverlays owns its canvas layers; a headless smoke has no
# display to raise it on, so it is built only with one.
# Screen-space UI (HUD, kill feed, chat, death screen) over the game camera, like the menu.
# MatchOverlays owns its canvas layers; built only with a display (skipped headless).
if not _is_headless():
_overlays = MatchOverlays.new()
add_child(_overlays)
# The minimap projects a click back to a world point and emits it; wire one to the player's
# order pipeline and one to the camera pan, so the panel itself owns no game state.
# Minimap emits a clicked world point; one wire orders the hero, one pans the camera.
_overlays.minimap.order_requested.connect(_on_minimap_order)
_overlays.minimap.look_requested.connect(_on_minimap_look)
# The error screen's two exits: tear the failed match down and reopen the menu, or quit.
_overlays.error.menu_requested.connect(_return_to_menu)
_overlays.error.quit_requested.connect(_quit_game)
_fog = FogOverlay.build(self)
## Reconciles the view pool against the live state, then trails the camera. Called each
## tick after the mode's step: a view is spawned the first time its entity is seen,
## updated while it persists, and freed once its id leaves the state (a dead unit).
## Reconciles the view pool against the live state, then trails the camera. Each tick after the
## step: spawn a view on first sight, update while it persists, free once its id leaves (dead).
func _sync_world() -> void:
var state := _active_state()
if state == null:
@@ -669,8 +670,7 @@ func _sync_world() -> void:
(_views[id]["root"] as Node3D).queue_free()
_views.erase(id)
if _fog != null:
# Fog of war: dim the unseen ground and hide enemies in it. A pure CLIENT's snapshot is
# already filtered to its team, so only a local-authority world needs the enemy-hiding pass.
# Fog: dim unseen ground, hide enemies. A CLIENT snapshot is pre-filtered; only local auth hides.
_fog.apply(state, _player_team(), _views, _mode != Mode.CLIENT)
for event in state.fx_events:
MatchFx.play(self, event)
@@ -686,10 +686,9 @@ func _sync_world() -> void:
_update_overlays(state)
## Trails the camera on the player's hero — re-pinned to it each tick it exists, held at its last
## sighting while it is gone (dead, pre-spawn), unless free-look holds a minimap-panned point that
## the re-centre key (SPACE, ignored while typing) drops. MatchCamera owns the easing; this hands it
## the hero's point and feeds the map decor the framed spot so growth over it fades to its outline.
## Trails the camera on the player's hero — re-pinned each tick it exists, held at its last sighting
## while gone (dead/pre-spawn), unless free-look holds a minimap-panned point that SPACE (ignored
## while typing) drops. MatchCamera eases; this feeds map decor the framed spot so growth fades.
func _follow_camera(state: SimState) -> void:
var hero := _camera_focus(state)
var recenter := Input.is_physical_key_pressed(KEY_SPACE) and not _chat_typing()
@@ -698,8 +697,7 @@ func _follow_camera(state: SimState) -> void:
_foliage_mat.set_shader_parameter("hero_pos", _world(_cam.target()))
## The unit the camera trails: the player's own hero. LOCAL drives `_hero_id`; a CLIENT
## reads its team's hero out of the render state; either way null before one exists.
## The camera's unit (the player's hero): LOCAL `_hero_id`, CLIENT its team's hero. Null first.
func _camera_focus(state: SimState) -> SimEntity:
if _mode == Mode.CLIENT:
return _local_hero(state)
@@ -708,22 +706,18 @@ func _camera_focus(state: SimState) -> SimEntity:
return null
## A right-click on the minimap: issue the player's move/attack order at that world point, through
## the same pipeline a world right-click uses, so it auto-paths and reconciles over the wire.
## Minimap right-click: issue the move/attack order at that point, via the world right-click path.
func _on_minimap_order(point: Vector2) -> void:
_player_input.order_at(_visible_state(), _player_hero_entity(), _player_team(), point)
## A left-click (or left-drag) on the minimap: pan the camera there for a free look, holding it off
## the hero until the player re-centres.
## Minimap left-click/drag: pan the camera there for a free look, held off the hero until re-centre.
func _on_minimap_look(point: Vector2) -> void:
_cam.look_at_point(point)
## Reconciles the whole screen-space UI each tick: the HUD, kill feed, and death screen all
## read off the player's focus hero (the camera's hero — sim-driven in LOCAL/HOST, read out of
## the snapshot on a CLIENT), so every overlay shows exactly what the player is driving. The
## kill feed also takes the two team colours for its lines.
## Reconciles the screen-space UI each tick: HUD, kill feed, death screen all read the focus hero
## (camera's hero — sim in LOCAL/HOST, snapshot on CLIENT). Kill feed also takes both team colours.
func _update_overlays(state: SimState) -> void:
if _overlays == null:
return
@@ -733,16 +727,13 @@ func _update_overlays(state: SimState) -> void:
)
## Whether the player is typing in chat — the driver suppresses ability casts while they are, so
## the letters of a message never fire the QWER bar. Movement (a mouse click) is left alone.
## Whether the player is typing in chat — casts are suppressed so message letters don't fire QWER.
func _chat_typing() -> bool:
return _overlays != null and _overlays.is_chat_typing()
## Builds an entity's pooled view: a primitive body (capsule unit, box structure), a
## flat ground ring for heroes, and a billboarded overlay carrying the HP bar, the
## resource bar (heroes), and the status label (heroes). Returns the node refs the
## per-tick update mutates, so nothing is rebuilt while the entity lives.
## Builds an entity's pooled view: a body, a flat ground ring (heroes), and a billboarded overlay
## (HP bar, plus resource bar + status label for heroes). Returns the refs the update mutates.
func _make_view(entity: SimEntity) -> Dictionary:
var root := Node3D.new()
root.position = _ground_at(entity.position)
@@ -763,13 +754,9 @@ func _make_view(entity: SimEntity) -> Dictionary:
return view
## Builds an entity's body under `root`: a size-normalised model — the hero's animal (by
## kit), a structure's tower/nexus, or a lane creep's slime — handed off to
## HeroModelLibrary, which stands it on the ground at its on-field size and washes it with
## the team colour. Returned so the view can hold it, though the body is never mutated
## again once built — team and form read off the tint and the ring, not the body. Only a
## pure CLIENT hero whose snapshot carried no `kit_id` falls through to the capsule, so an
## unmodelled hero still draws.
## Builds an entity's body under `root`: a size-normalised model (hero's animal by kit, tower/nexus,
## or creep slime) via HeroModelLibrary, stood on the ground at on-field size and team-coloured.
## Never mutated after (team/form read tint + ring). A CLIENT hero with no `kit_id` → capsule.
func _build_body(root: Node3D, entity: SimEntity) -> Node3D:
if entity.is_hero and HeroModelLibrary.has_model(entity.kit_id):
return HeroModelLibrary.add_to(root, entity.kit_id, _team_color(entity.team))
@@ -786,8 +773,7 @@ func _build_body(root: Node3D, entity: SimEntity) -> Node3D:
return body
## Hangs the floating UI above an entity: an HP bar for anything with health, plus a
## resource bar and a status label for a hero. Creeps get only a lower HP bar.
## Floating UI above an entity: an HP bar for all, plus resource bar + status label for heroes.
func _attach_overlay(view: Dictionary, entity: SimEntity) -> void:
var root: Node3D = view["root"]
var hp_y := _hp_bar_y(view["body"])
@@ -811,8 +797,7 @@ func _attach_overlay(view: Dictionary, entity: SimEntity) -> void:
view["status"] = label
## Reconciles one view with its entity: position, facing, the form-ring colour, the bar
## fills, and the status label. Cheap per-tick mutation only — no node is created here.
## Reconciles one view: position, facing, ring colour, bar fills, status label. No node created.
func _update_view(view: Dictionary, entity: SimEntity) -> void:
var root := view["root"] as Node3D
var placed := _ground_at(entity.position)
@@ -833,16 +818,14 @@ func _update_view(view: Dictionary, entity: SimEntity) -> void:
StatusLabel.refresh(view["status"], entity)
## Left-anchors a bar's fill to `frac` of its full width by scaling the foreground quad
## and sliding it so its left edge stays put. The follow-camera holds a fixed yaw, so a
## billboarded quad's local x maps to screen x and the fill always reads horizontally.
## Left-anchors a bar's fill to `frac` of full width: scale the foreground quad and slide it so the
## left edge stays put. The fixed yaw maps the billboard's local x to screen x (fill horizontal).
func _set_bar(fg: MeshInstance3D, frac: float) -> void:
fg.scale.x = maxf(frac, 0.0001)
fg.position.x = -BAR_WIDTH * 0.5 * (1.0 - frac)
## A billboarded HP/resource bar: a dark background quad with a coloured foreground quad
## over it, both returned with the foreground so `_set_bar` can scale the fill.
## A billboarded bar: a dark bg quad with a coloured fg quad over it; returns both for `_set_bar`.
func _make_bar(fg_color: Color, y: float) -> Dictionary:
var node := Node3D.new()
node.position = Vector3(0.0, y, 0.0)
@@ -882,19 +865,16 @@ func _ring_mesh() -> TorusMesh:
return torus
## Half the body's height, the lift that stands it on the ground (its origin-centred
## mesh otherwise sinks halfway under y = 0).
## Half the body height — the lift standing it on the ground (else the centred mesh sinks below 0).
func _body_half_height(entity: SimEntity) -> float:
if entity.is_structure:
return STRUCTURE_HEIGHT * 0.5
return (CREEP_BODY_HEIGHT if entity.is_creep else HERO_BODY_HEIGHT) * 0.5
## The height a unit's HP bar floats at — a fixed gap above its own model's measured top, so
## a short body (the chameleon, a slime creep) and a tall one (the hyena, a tower) both read
## with the bar tucked just above them rather than at one shared height tuned to nothing in
## particular. Every field body is now a model, so the one measured-top rule covers heroes,
## creeps, and structures alike; only the pure-CLIENT capsule fallback has no model to measure.
## HP bar height: a fixed gap above the model's measured top, so a short body (chameleon, slime) and
## a tall one (hyena, tower) both tuck the bar just above. Every field body is a model, so one
## measured-top rule covers heroes/creeps/structures; only the CLIENT capsule fallback has none.
func _hp_bar_y(body: Node3D) -> float:
return HeroModelLibrary.top_of(body) + HERO_BAR_GAP
@@ -919,18 +899,16 @@ func _flat_material(color: Color) -> StandardMaterial3D:
return mat
## The jungle short-grass material the ground plane wears: the shared grass shader, which
## breaks the plane into toon-quantised patches of two greens and cel-bands the light to
## match the units. A fresh instance so the one ground plane owns its own material.
## The ground plane's jungle short-grass material: the shared grass shader (toon-quantised patches
## of two greens, cel-banded light to match units). A fresh instance so the plane owns its material.
func _ground_material() -> ShaderMaterial:
var mat := ShaderMaterial.new()
mat.shader = GROUND_SHADER
return mat
## An unshaded, always-camera-facing material with depth-test off, so a floating bar or
## label reads at full colour over the lit world and the foreground quad layers cleanly
## over its background by draw order rather than fighting it on depth.
## An unshaded, billboarded material with depth-test off, so a floating bar/label reads at full
## colour over the lit world and the fg quad layers over its bg by draw order, not depth.
func _bar_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
@@ -945,10 +923,8 @@ func _team_color(team: int) -> Color:
return HERO_COLOR if team == HERO_TEAM else BOT_COLOR
## A hero's draw colour: its team colour shaded by its roster seat, so squadmates on one
## team read apart while still wearing the team hue. A non-hero or a hero whose kit sits in
## no tribe (an unequipped or unknown one) keeps the flat team colour. Structures and creeps
## use `_team_color` directly — only heroes share a team three at a time.
## A hero's draw colour: team colour shaded by roster seat, so squadmates read apart while keeping
## the team hue. A hero whose kit sits in no tribe keeps the flat colour (only heroes share a team).
func _hero_color(entity: SimEntity) -> Color:
var base := _team_color(entity.team)
var slot := AbilityData.roster_index(entity.kit_id)
@@ -958,10 +934,8 @@ func _hero_color(entity: SimEntity) -> Color:
return base.lightened(shade) if shade >= 0.0 else base.darkened(-shade)
## This tick's player command — delegated to PlayerInput, handed the world the player acts on,
## their hero, their team, and whether to sample casts. Casts are sampled only with a local
## authoritative sim and while the player is not typing in chat, so a letter in a message never
## fires its QWER bind.
## This tick's player command via PlayerInput, handed the world, hero, team, and whether to sample
## casts. Casts only with a local sim and not typing, so a message letter never fires its QWER bind.
func _sample_player_input() -> InputCommand:
return _player_input.sample(
_visible_state(), _player_hero_entity(), _player_team(),
@@ -969,14 +943,12 @@ func _sample_player_input() -> InputCommand:
)
## Whether the cursor sits over the minimap this tick — the world right-click order is skipped when
## it does, so the panel's own order is the only one (no stray move under the card). False headless.
## Cursor over the minimap: world right-click order is skipped (panel's own only). False headless.
func _pointer_over_minimap() -> bool:
return _overlays != null and _overlays.minimap.contains_pointer()
## The state the player acts on: the live sim where this client owns authority (LOCAL/HOST),
## or the latest snapshot on a pure CLIENT. Null before one exists.
## State the player acts on: the live sim with local authority (LOCAL/HOST), else latest snapshot.
func _visible_state() -> SimState:
if _mode == Mode.CLIENT:
return _net.latest_state() if _net != null else null
modified src/client/match_overlays.gd
@@ -7,15 +7,18 @@ extends CanvasLayer
## four overlays — the hero HUD, the kill feed, the chat box, and the death screen — and drives
## them from one `update` the driver calls each tick, so main holds one field and one call.
##
## The four are added in draw order: HUD, feed, and chat first, the death screen last, so the
## death dim falls over the rest when the player's hero is down. Built only when there is a
## display (main skips it on a headless smoke), so every overlay here may assume a screen.
## The overlays are added in draw order: HUD, feed, chat, and minimap first; the death dim over
## them; the error screen last of all, so its opaque cover falls over everything when a match fails.
## Built only when there is a display (main skips it on a headless smoke), so every overlay here may
## assume a screen. The error screen owns no networking — main shows it on a failure and acts on its
## two signals (back to menu / quit), the same way the death screen is driven from the hero's state.
var hud: MatchHud
var kill_feed: KillFeed
var chat: MatchChat
var minimap: Minimap
var death: DeathOverlay
var error: ErrorOverlay
func _ready() -> void:
@@ -25,12 +28,15 @@ func _ready() -> void:
chat = MatchChat.new()
minimap = Minimap.new()
death = DeathOverlay.new()
# Draw order: the death screen is added last so its dim layers over the HUD and minimap when shown.
error = ErrorOverlay.new()
# Draw order: the death dim is added over the HUD/minimap; the error cover is added last of all,
# so a failure's opaque screen layers over the death dim too.
add_child(hud)
add_child(kill_feed)
add_child(chat)
add_child(minimap)
add_child(death)
add_child(error)
## Reconciles every overlay against this tick's world. `focus` is the player's own hero (null
modified src/net/net_session.gd
@@ -22,6 +22,8 @@ signal client_left(peer_id: int)
signal joined_server(team: int)
## Client: the server refused us (today: a protocol-version mismatch).
signal rejected(reason: String)
## Client: the connection attempt reached no server — nothing answered at the address.
signal connect_failed
## Client: the server connection was lost.
signal server_left
@@ -78,6 +80,7 @@ func start_client(address: String, port: int = DEFAULT_PORT) -> Error:
multiplayer.multiplayer_peer = peer
is_server = false
multiplayer.connected_to_server.connect(_on_connected_to_server)
multiplayer.connection_failed.connect(func() -> void: connect_failed.emit())
multiplayer.server_disconnected.connect(func() -> void: server_left.emit())
return OK
added test/unit/test_error_codes.gd
@@ -0,0 +1,29 @@
extends GutTest
## Checks on the player-facing error catalogue — the small fixed table the error screen reads.
## It covers the two guarantees a caller leans on: every shipped code has a stable badge and a
## headline, and an unknown code still formats rather than crashing the one screen meant to explain
## a failure. No display, no networking — `ErrorOverlay` owns those; this is the whole of the data.
func test_label_formats_a_code_as_a_badge() -> void:
assert_eq(ErrorCode.label(ErrorCode.UNREACHABLE), "E-1003")
assert_eq(ErrorCode.label(ErrorCode.CANT_HOST), "E-1001")
func test_every_shipped_code_has_a_headline() -> void:
for code in [
ErrorCode.CANT_HOST,
ErrorCode.CANT_CONNECT,
ErrorCode.UNREACHABLE,
ErrorCode.REFUSED,
ErrorCode.LOST,
]:
assert_ne(ErrorCode.title(code), "", "code %d has a headline" % code)
assert_ne(ErrorCode.title(code), "Something went wrong", "code %d is not the fallback" % code)
func test_an_unknown_code_still_formats() -> void:
# A code not in the table must never crash the error screen — the one screen that explains a
# failure should always have a badge and a line, even for a number nobody catalogued.
assert_eq(ErrorCode.label(9999), "E-9999", "an unlisted code still reads as a badge")
assert_eq(ErrorCode.title(9999), "Something went wrong", "an unlisted code gets a generic line")