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 ,,` 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 ,,` 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)