GDScript 969 lines
extends Node3D
## 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 }
const HERO_SPEED := 215.0
const BOT_SPEED := 200.0
const HERO_TEAM := 0
const BOT_TEAM := 1
const DEFAULT_JOIN_ADDRESS := "127.0.0.1"
## 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) ----------------------------------------------------
# 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 (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 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 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 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 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
const RES_BAR_DROP := 36.0
const STATUS_LABEL_RISE := 70.0
const HP_BAR_BG := Color(0.0, 0.0, 0.0, 0.55)
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
## 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"
## Kit both heroes mirror in a HOST/CLIENT duel — the one-per-team skeleton until multi-hero wire.
const DUEL_KIT := "lion"
## 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)
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 was passed: flagged/headless launches enter directly, bare windowed → menu.
var _explicit_mode := false
## 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 starts; gates the per-tick driver and draw so the menu sits over a static map.
var _started := false
## 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/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 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: 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]
## 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 (two squadmates + three opponents), each stepped by BotController.
var _bot_ids: Array[int] = []
var _net: NetSession = null
## HOST: the peer id controlling team 1, or 0 while the slot is bot-filled.
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: world to draw — remote entities interpolated in the past, own hero overlaid at present.
var _client_state: SimState = null
## CLIENT: buffers recent snapshots, interpolating remote entities to smooth jitter and drops.
var _interp := SnapshotInterpolator.new()
## CLIENT: monotonic input seq stamped on each input, so the server's ack matches a pending input.
var _input_seq: int = 0
## CLIENT: unacked inputs (oldest first, `{seq, input}`), replayed onto each snapshot; acks prune.
var _pending_inputs: Array[Dictionary] = []
## 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
## Shared map-decor material (JungleDecor); fed the hero's position so growth over it fades.
var _foliage_mat: ShaderMaterial = null
var _views: Dictionary = {}
## 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
## 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()
# "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()
func _physics_process(_delta: float) -> void:
if not _started:
return
match _mode:
Mode.HOST:
_tick_host()
Mode.CLIENT:
_tick_client()
_:
_tick_local()
_sync_world()
# --- Mode setup -------------------------------------------------------------
func _configure_from_cmdline() -> void:
var args := OS.get_cmdline_user_args()
var i := 0
while i < args.size():
var arg := args[i]
if arg == "--host":
_mode = Mode.HOST
_explicit_mode = true
elif arg == "--join":
_mode = Mode.CLIENT
_explicit_mode = true
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_join_address = args[i + 1]
i += 1
elif arg == "--local":
_mode = Mode.LOCAL
_explicit_mode = true
elif arg == "--hero":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_player_hero = args[i + 1]
i += 1
elif arg == "--bot-difficulty":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_set_bot_difficulty(args[i + 1])
i += 1
elif arg == "--netsim":
if i + 1 < args.size() and not args[i + 1].begins_with("--"):
_netsim_params = _parse_netsim(args[i + 1])
i += 1
i += 1
## 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 = []
for field in fields:
if not field.is_valid_float():
push_warning("ignoring malformed --netsim value %s (want latency,jitter,loss)" % value)
return []
nums.append(field.to_float())
return [
maxf(0.0, (nums[0] if nums.size() > 0 else 0.0)),
maxf(0.0, (nums[1] if nums.size() > 1 else 0.0)),
clampf((nums[2] if nums.size() > 2 else 0.0), 0.0, 1.0),
]
## 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
else:
push_warning("unknown --bot-difficulty %s; keeping %s (want easy|normal|hard)" % [
level_name, _bot_difficulty
])
## 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:
ok = _start_host()
Mode.CLIENT:
ok = _start_client()
_:
_start_local()
# 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/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"
## 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
menu.default_hero = _player_hero
menu.default_difficulty = _bot_difficulty
menu.practice_requested.connect(_on_practice_requested)
menu.host_requested.connect(_on_host_requested)
menu.join_requested.connect(_on_join_requested)
_menu_layer = CanvasLayer.new()
_menu_layer.add_child(menu)
add_child(_menu_layer)
## 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
_set_bot_difficulty(difficulty)
_close_menu_and_enter()
func _on_host_requested() -> void:
_mode = Mode.HOST
_close_menu_and_enter()
func _on_join_requested(address: String) -> void:
_mode = Mode.CLIENT
_join_address = address
_close_menu_and_enter()
## 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()
_menu_layer = null
_enter_match()
## 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)
if player_tribe == "":
var fallback: String = AbilityData.TRIBE[DEFAULT_TRIBE][0]
push_warning("unknown --hero %s; defaulting to %s" % [_player_hero, fallback])
_player_hero = fallback
player_tribe = DEFAULT_TRIBE
var player_roster: Array[String] = []
player_roster.assign(AbilityData.TRIBE[player_tribe])
var bot_roster: Array[String] = []
bot_roster.assign(AbilityData.TRIBE[AbilityData.opposing_tribe(player_tribe)])
_seat_squad(HERO_TEAM, HERO_SPEED, player_roster, player_roster.find(_player_hero))
_seat_squad(BOT_TEAM, BOT_SPEED, bot_roster, -1)
## 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)
_sim.equip_kit(id, roster[i])
if i == player_slot:
_hero_id = id
else:
_bot_ids.append(id)
## 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 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 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() -> 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:
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() -> bool:
_net = _make_session()
var err := _net.start_client(_join_address)
if err != OK:
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(
"simulating link: %d ms latency, %d ms jitter, %d%% loss"
% [_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:
var net := NetSession.new()
net.name = "NetSession"
add_child(net)
return net
# --- Per-tick drivers -------------------------------------------------------
func _tick_local() -> void:
var inputs := {_hero_id: _sample_player_input()}
for id in _bot_ids:
inputs[id] = _bot.decide(_sim.state, id)
_sim.step(inputs)
func _tick_host() -> void:
var team1_command: InputCommand
var ack := -1
if _team1_peer != 0:
var remote := _net.input_for(_team1_peer)
team1_command = remote if remote != null else InputCommand.new()
ack = _net.input_seq_for(_team1_peer)
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 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 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 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)
_pending_inputs.append({"seq": _input_seq, "input": command})
_buffer_snapshots()
_client_state = _render_state()
## 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():
for delivered in _net.drain_snapshots(now):
_interp.push(delivered["state"], delivered["time"])
else:
var state := _net.latest_state()
if state != null:
_interp.push(state, now)
## 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:
return null
_overlay_predicted_hero(state)
return state
## 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:
return
var hero := _local_hero(state)
if hero != null:
hero.position = predicted.position
## 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:
return null
var hero := _local_hero(state)
if hero == null:
return null
var ack := _net.latest_ack()
while not _pending_inputs.is_empty() and _pending_inputs[0]["seq"] <= ack:
_pending_inputs.pop_front()
for entry in _pending_inputs:
SimCore.apply_movement(hero, entry["input"])
return hero
## Our hero in `state`: the one mobile, non-creep unit on our team (one hero per team today).
func _local_hero(state: SimState) -> SimEntity:
for id in state.entities:
var entity: SimEntity = state.entities[id]
if entity.team == _my_team and not entity.is_structure and not entity.is_creep:
return entity
return null
# --- Network event handlers -------------------------------------------------
func _on_client_joined(peer_id: int, team: int) -> void:
_team1_peer = peer_id
print("client connected: peer %d controls team %d" % [peer_id, team])
func _on_client_left(peer_id: int) -> void:
if peer_id == _team1_peer:
_team1_peer = 0
print("client disconnected: peer %d — team 1 reverts to a bot" % peer_id)
func _on_joined_server(team: int) -> void:
_joined = true
_my_team = team
print("joined the server as team %d" % team)
func _on_rejected(reason: String) -> void:
_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:
_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 --------------------------------------------------------------
## 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 -------------------------------------
# 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).
func _world(p: Vector2) -> Vector3:
return Vector3(p.x, 0.0, p.y)
## 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: 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
env.background_color = BACKDROP_COLOR
env.ambient_light_source = Environment.AMBIENT_SOURCE_COLOR
env.ambient_light_color = AMBIENT_COLOR
env.ambient_light_energy = AMBIENT_ENERGY
var world_env := WorldEnvironment.new()
world_env.environment = env
add_child(world_env)
var light := DirectionalLight3D.new()
light.rotation_degrees = Vector3(-60.0, -45.0, 0.0)
light.light_energy = LIGHT_ENERGY
add_child(light)
_ground = MeshInstance3D.new()
var plane := PlaneMesh.new()
plane.size = MapData.BOUNDS.size
_ground.mesh = plane
_ground.position = _world(MapData.BOUNDS.get_center())
_ground.material_override = _ground_material()
add_child(_ground)
MapView.build(self)
_foliage_mat = JungleDecor.build(self)
_cam = MatchCamera.new(Callable(self, "_world"))
add_child(_cam.node)
_cam.place(MapData.BOUNDS.get_center())
_player_input = PlayerInput.new(_cam.node)
_move_marker = MoveMarker.new()
add_child(_move_marker)
# 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)
# 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. 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:
return
for id in state.entities:
var entity: SimEntity = state.entities[id]
if not _views.has(id):
_views[id] = _make_view(entity)
_update_view(_views[id], entity)
for id in _views.keys():
if not state.entities.has(id):
(_views[id]["root"] as Node3D).queue_free()
_views.erase(id)
if _fog != null:
# 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)
for attack in state.attack_events:
CombatFx.strike(self, attack)
for hit in state.hit_events:
CombatFx.number(self, hit)
if _player_input.has_move_target:
_move_marker.point_at(_player_input.move_target)
else:
_move_marker.clear()
_follow_camera(state)
_update_overlays(state)
## 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()
_cam.follow(hero.position if hero != null else Vector2.ZERO, hero != null, recenter)
if _foliage_mat != null:
_foliage_mat.set_shader_parameter("hero_pos", _world(_cam.target()))
## 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)
if state.entities.has(_hero_id):
return state.entities[_hero_id]
return null
## 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)
## 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 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
_overlays.update(
_camera_focus(state), state, _player_team(), [HERO_COLOR, BOT_COLOR],
SimCore.TICK_RATE, _mode != Mode.CLIENT,
)
## 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 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)
add_child(root)
var view := {"root": root}
view["body"] = _build_body(root, entity)
if entity.is_hero and HeroModelLibrary.has_model(entity.kit_id):
HeroModelLibrary.setup_facing(view, entity.kit_id, view["body"])
HeroModelLibrary.add_shadow(root, view["body"])
if entity.is_hero:
var ring := MeshInstance3D.new()
ring.mesh = _ring_mesh()
ring.position = Vector3(0.0, HeroModelLibrary.SHADOW_Y + 1.0, 0.0) # over the shadow blob
ring.material_override = _flat_material(HUMAN_RING_COLOR)
root.add_child(ring)
view["ring"] = ring
_attach_overlay(view, entity)
return view
## 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))
if entity.is_structure:
var prop := "nexus" if entity.is_nexus else "tower"
return HeroModelLibrary.add_prop(root, prop, _team_color(entity.team))
if entity.is_creep:
return HeroModelLibrary.add_prop(root, "creep", _team_color(entity.team).darkened(CREEP_DARKEN))
var body := MeshInstance3D.new()
body.mesh = _body_mesh(entity)
body.position = Vector3(0.0, _body_half_height(entity), 0.0)
body.material_override = _flat_material(_body_color(entity))
root.add_child(body)
return body
## 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"])
var hp := _make_bar(HP_BAR_FG, hp_y)
root.add_child(hp["node"])
view["hp_node"] = hp["node"]
view["hp_fg"] = hp["fg"]
if not entity.is_hero:
return
var res := _make_bar(RES_BAR_FG, hp_y - RES_BAR_DROP)
root.add_child(res["node"])
view["res_node"] = res["node"]
view["res_fg"] = res["fg"]
var label := Label3D.new()
label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
label.no_depth_test = true
label.font_size = STATUS_FONT_SIZE
label.outline_size = STATUS_FONT_SIZE / 6
label.position = Vector3(0.0, hp_y + STATUS_LABEL_RISE, 0.0)
root.add_child(label)
view["status"] = label
## 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)
var moved := placed - root.position
root.position = placed
root.visible = not entity.is_dead() # a downed hero's body vanishes behind the death screen
if view.has("yaw"):
HeroModelLibrary.drive_facing(view, view["body"], Vector2(moved.x, moved.z))
if view.has("ring"):
var mat := (view["ring"] as MeshInstance3D).material_override as StandardMaterial3D
var animal := entity.form == AbilitySpec.FORM_ANIMAL
mat.albedo_color = ANIMAL_RING_COLOR if animal else HUMAN_RING_COLOR
_set_bar(view["hp_fg"], _fraction(entity.hp, entity.max_hp))
if view.has("res_node"):
(view["res_node"] as Node3D).visible = entity.resource_max > 0
_set_bar(view["res_fg"], _fraction(entity.resource, entity.resource_max))
if view.has("status"):
StatusLabel.refresh(view["status"], entity)
## 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 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)
var bg := MeshInstance3D.new()
bg.mesh = _bar_quad()
bg.material_override = _bar_material(HP_BAR_BG)
node.add_child(bg)
var fg := MeshInstance3D.new()
fg.mesh = _bar_quad()
fg.material_override = _bar_material(fg_color)
node.add_child(fg)
return {"node": node, "fg": fg}
func _bar_quad() -> QuadMesh:
var quad := QuadMesh.new()
quad.size = Vector2(BAR_WIDTH, BAR_HEIGHT)
return quad
func _body_mesh(entity: SimEntity) -> Mesh:
if entity.is_structure:
var box := BoxMesh.new()
var w := NEXUS_SIZE if entity.is_nexus else TOWER_SIZE
box.size = Vector3(w, STRUCTURE_HEIGHT, w)
return box
var capsule := CapsuleMesh.new()
capsule.radius = CREEP_RADIUS if entity.is_creep else ENTITY_RADIUS
capsule.height = CREEP_BODY_HEIGHT if entity.is_creep else HERO_BODY_HEIGHT
return capsule
func _ring_mesh() -> TorusMesh:
var torus := TorusMesh.new()
torus.inner_radius = FORM_RING_RADIUS - FORM_RING_THICKNESS
torus.outer_radius = FORM_RING_RADIUS
return torus
## 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
## 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
func _body_color(entity: SimEntity) -> Color:
if entity.is_creep:
return _team_color(entity.team).darkened(CREEP_DARKEN)
if entity.is_hero:
return _hero_color(entity)
return _team_color(entity.team)
func _fraction(current: int, max_value: int) -> float:
if max_value <= 0:
return 0.0
return clampf(float(current) / float(max_value), 0.0, 1.0)
func _flat_material(color: Color) -> StandardMaterial3D:
var mat := StandardMaterial3D.new()
mat.albedo_color = color
return mat
## 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, 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
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.billboard_mode = BaseMaterial3D.BILLBOARD_ENABLED
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.no_depth_test = true
return mat
func _team_color(team: int) -> Color:
return HERO_COLOR if team == HERO_TEAM else BOT_COLOR
## 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)
if slot < 0 or slot >= HERO_SHADES.size():
return base
var shade := HERO_SHADES[slot]
return base.lightened(shade) if shade >= 0.0 else base.darkened(-shade)
## 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(),
_sim != null and not _chat_typing(), _pointer_over_minimap()
)
## 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()
## 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
return _sim.state if _sim != null else null
## The player's team — HERO_TEAM with local authority, the server-assigned team on a CLIENT.
func _player_team() -> int:
return _my_team if _mode == Mode.CLIENT else HERO_TEAM
## The player's own hero, what movement is measured from: our team's hero in the visible state.
func _player_hero_entity() -> SimEntity:
var state := _visible_state()
if state == null:
return null
return _local_hero(state) if _mode == Mode.CLIENT else state.get_entity(_hero_id)