extends CanvasLayer class_name PlayerHUD ## The first-person HUD, in the same hand as the rest of the game. ## ## It is one place now. The reticle used to be five white ColorRects pasted into ## three different level runtimes, the vitals were two stock ProgressBars with a ## flat colour override buried 1200 lines into the movement controller, and the ## ammo count — the single number a shooter's player looks at most — was not on ## screen at all. Nothing shared the ink edge, the lean or the palette that every ## menu in the game is built from, so crossing from the main menu into a match ## looked like crossing into a different product. ## ## The layout follows the one rule that matters for a HUD: the player is looking ## at the CENTRE of the screen, so everything the HUD says is arranged by how ## urgently it needs to interrupt that. ## ## centre the reticle, and confirmations, and the reload ring. Read ## constantly, without moving the eye. ## bottom left vitals, and the movement chain above them. Glanced at between ## engagements. ## bottom right ammo, grenades, ability cooldowns. Same. ## top left the debug readout, and only when a setting asks for it. ## nowhere else everything that is not one of those things. ## ## Match state — timer, score, killfeed, scoreboard — belongs to ui/match_hud.gd ## and is deliberately not duplicated here. ## ## ── Why this owns the abilities and the chain ──────────────────────────────── ## ## Those used to be built by the LEVEL, in three separate runtime scripts that ## each carried their own byte-identical copy of a black rounded panel. A level ## cannot know a player's dash cooldown without reaching down into that player's ## state machine every frame, which is what all three did, and it cannot show the ## right thing in a split-screen or spectator case at all. More immediately: the ## level's ammo panel and this one's both existed, so the screen showed the ammo ## count twice, in two different styles, overlapping. ## ## The rule is that anything describing THE PLAYER belongs to the player's HUD, ## and a level owns the level. ## The player this HUD belongs to. Set before adding to the tree. var player: Node = null var crosshair: Crosshair var reload_ring: ReloadRing var death_screen: Control var _health: VitalBar var _shield: VitalBar var _health_num: Label var _shield_num: Label var _shield_row: Control var _ammo_num: Label var _ammo_max: Label var _weapon_name: Label var _ammo_card: PanelContainer var _grenade_row: HBoxContainer var _chain_row: Control var _chain_num: Label var _chain_bonus: Label var _shown_chain: int = 0 var _dash_chip: AbilityChip var _grapple_chip: AbilityChip var _debug_box: VBoxContainer var _fps_line: Label var _speed_line: Label var _state_line: Label var _wman: Node = null var _machine: Node = null ## Crosshair bloom sources, all 0..1, combined as the largest rather than the sum ## so a sprinting player who fires does not blow the reticle off the screen. var _fire_bloom: float = 0.0 var _shown_ammo: int = -1 ## Speed at which movement alone fully blooms the reticle. const BLOOM_SPEED := 14.0 ## How fast a shot's bloom recovers. Roughly a fifth of a second, which is short ## enough to keep pace with an automatic weapon. const FIRE_RECOVER := 5.0 const SPREAD_SMOOTH := 12.0 var _spread: float = 0.0 ## Below this fraction of a magazine the ammo readout goes volt and the count ## starts to matter. const LOW_AMMO := 0.34 func _ready() -> void: layer = 1 # above the weapon viewmodel's canvas, which sits at 0 UITheme.apply_global(get_tree()) _build_reticle() _build_vitals() _build_ammo() _build_abilities() _build_debug() _build_death_screen() # ── Public API ─────────────────────────────────────────────────────────────── func set_vitals(hp: float, hp_max: float, sh: float, sh_max: float) -> void: _health.max_value = hp_max _health.value = hp _health_num.text = str(int(ceil(hp))) # Health goes volt-tinted when it is the last thing standing between the # player and a respawn. The bar breathes at the same threshold. var frac: float = hp / maxf(hp_max, 1.0) _health_num.add_theme_color_override("font_color", UITheme.VOLT if frac <= VitalBar.LOW_AT else UITheme.PAPER) _shield.max_value = sh_max _shield.value = sh _shield_num.text = str(int(ceil(sh))) # A depleted shield is not information worth a permanent row — it hides, and # reappears the moment it starts recharging. One less thing on screen. _shield_row.visible = sh > 0.5 ## A landed shot. `fatal` gets the louder kill confirmation. func confirm_hit(fatal: bool = false) -> void: if crosshair: crosshair.confirm(fatal) # ── Build ──────────────────────────────────────────────────────────────────── func _build_reticle() -> void: crosshair = Crosshair.new() crosshair.name = "Crosshair" add_child(crosshair) reload_ring = load("res://ui/reload_ring.gd").new() reload_ring.name = "ReloadRing" # Deliberately NOT re-anchored here. The ring centres itself inside its own # full rect; a PRESET_CENTER here ran after its `_ready` and undid that, # which is how it ended up as a zero-sized control at (960, 960). # The ring sits outside the reticle's bloom so a reload in progress never # obscures the ticks, and it is papaya rather than white so it belongs. reload_ring.radius = 34.0 reload_ring.thickness = 5.0 reload_ring.color = UITheme.PAPAYA add_child(reload_ring) func _build_vitals() -> void: # Full rect plus margins — see the note in `_build_ammo` for why a corner # preset collapses. var margin := MarginContainer.new() margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_left", 34) margin.add_theme_constant_override("margin_bottom", 30) margin.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(margin) # Row pushes left, column pushes down — see the note in `_build_ammo`. var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_BEGIN row.mouse_filter = Control.MOUSE_FILTER_IGNORE margin.add_child(row) var col := VBoxContainer.new() col.alignment = BoxContainer.ALIGNMENT_END col.size_flags_vertical = Control.SIZE_SHRINK_END col.add_theme_constant_override("separation", 8) col.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(col) _build_chain(col) # Shield above health: it is the layer that goes first, so it reads as the # outer one. Its whole row hides when empty rather than sitting at zero. var sh := _vital_row("SHIELD", UITheme.CYAN, 30, 200.0) _shield = sh[0] _shield_num = sh[1] _shield_row = sh[2] col.add_child(_shield_row) var hp := _vital_row("HEALTH", UITheme.PAPAYA, 46, 250.0) _health = hp[0] _health_num = hp[1] col.add_child(hp[2]) ## One vital: a tag, the bar, and the numeral BESIDE the bar. ## ## Beside, not centred on it — see the note in VitalBar. Returns ## `[bar, numeral, row]`, because the caller needs all three. func _vital_row(tag: String, col: Color, num_size: int, width: float) -> Array: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 12) row.mouse_filter = Control.MOUSE_FILTER_IGNORE var stack := VBoxContainer.new() stack.add_theme_constant_override("separation", 1) row.add_child(stack) var label := Label.new() label.text = tag label.add_theme_font_size_override("font_size", 15) label.add_theme_color_override("font_color", col) label.add_theme_color_override("font_outline_color", UITheme.INK) label.add_theme_constant_override("outline_size", 5) stack.add_child(label) var bar := VitalBar.new() bar.fill_color = col bar.custom_minimum_size = Vector2(width, 22) stack.add_child(bar) var num := Label.new() num.text = "100" num.add_theme_font_size_override("font_size", num_size) num.add_theme_color_override("font_color", UITheme.PAPER) num.add_theme_color_override("font_outline_color", UITheme.INK) num.add_theme_constant_override("outline_size", 9) num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM row.add_child(num) return [bar, num, row] func _build_ammo() -> void: # FULL RECT plus margins, not a corner preset. # # A MarginContainer anchored to BOTTOM_RIGHT has zero size and grows from the # corner, so its `margin_right` pushes the content INTO a container that is # not there — the card collapsed to a sliver hanging off the right edge of # the screen with the ammo count clipped inside it. Owning the whole rect and # aligning to the end is unambiguous, and it is what the ability row does. var margin := MarginContainer.new() margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_right", 34) margin.add_theme_constant_override("margin_bottom", 30) margin.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(margin) # A BoxContainer's own `alignment` is the reliable way to push content to an # edge; a SHRINK_END size flag on the box itself is not, because the box's # minimum width depends on children that may be hidden and it ends up placed # by its own START. So the corner is built out of two boxes, each aligning on # the axis it actually controls: the row pushes right, the column pushes # down. This is the same scaffold the ability row uses. var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_END row.mouse_filter = Control.MOUSE_FILTER_IGNORE margin.add_child(row) var col := VBoxContainer.new() col.alignment = BoxContainer.ALIGNMENT_END col.size_flags_vertical = Control.SIZE_SHRINK_END col.add_theme_constant_override("separation", 6) col.mouse_filter = Control.MOUSE_FILTER_IGNORE row.add_child(col) # Grenades as pips rather than a number: three of a thing is countable, and # it matches the segmented vitals rather than introducing a second idiom. _grenade_row = HBoxContainer.new() _grenade_row.alignment = BoxContainer.ALIGNMENT_END _grenade_row.add_theme_constant_override("separation", 5) col.add_child(_grenade_row) _ammo_card = UITheme.card() _ammo_card.mouse_filter = Control.MOUSE_FILTER_IGNORE col.add_child(_ammo_card) var inner := VBoxContainer.new() inner.alignment = BoxContainer.ALIGNMENT_END inner.add_theme_constant_override("separation", 0) _ammo_card.add_child(inner) _weapon_name = Label.new() _weapon_name.text = "" _weapon_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _weapon_name.add_theme_font_size_override("font_size", 18) _weapon_name.add_theme_color_override("font_color", UITheme.PAPER_DIM) _weapon_name.add_theme_color_override("font_outline_color", UITheme.INK) _weapon_name.add_theme_constant_override("outline_size", 5) inner.add_child(_weapon_name) # The two halves of the count are separate labels at different sizes, so the # magazine reads as the number and the reserve reads as context. One # "12 / 30" at a single size makes the player parse a string. var count := HBoxContainer.new() count.alignment = BoxContainer.ALIGNMENT_END count.add_theme_constant_override("separation", 4) inner.add_child(count) _ammo_num = Label.new() _ammo_num.text = "--" _ammo_num.add_theme_font_size_override("font_size", 58) _ammo_num.add_theme_color_override("font_color", UITheme.PAPER) _ammo_num.add_theme_color_override("font_outline_color", UITheme.INK) _ammo_num.add_theme_constant_override("outline_size", 10) _ammo_num.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM count.add_child(_ammo_num) _ammo_max = Label.new() _ammo_max.text = "" _ammo_max.add_theme_font_size_override("font_size", 24) _ammo_max.add_theme_color_override("font_color", UITheme.PAPER_DIM) _ammo_max.add_theme_color_override("font_outline_color", UITheme.INK) _ammo_max.add_theme_constant_override("outline_size", 6) _ammo_max.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM count.add_child(_ammo_max) ## Dash and grapple, above the ammo card on the right. ## ## Right-hand side because they are the other half of "what can I do right now", ## which is the question the ammo count answers. Putting cooldowns on the left ## with the vitals would split that question across the screen. func _build_abilities() -> void: var margin := MarginContainer.new() margin.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) margin.add_theme_constant_override("margin_right", 34) margin.add_theme_constant_override("margin_bottom", 190) margin.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(margin) var row := HBoxContainer.new() row.alignment = BoxContainer.ALIGNMENT_END row.size_flags_vertical = Control.SIZE_SHRINK_END row.add_theme_constant_override("separation", 8) row.mouse_filter = Control.MOUSE_FILTER_IGNORE margin.add_child(row) _grapple_chip = AbilityChip.new() _grapple_chip.label = "GRAPPLE" _grapple_chip.custom_minimum_size = Vector2(116, 34) row.add_child(_grapple_chip) _dash_chip = AbilityChip.new() _dash_chip.label = "DASH" row.add_child(_dash_chip) ## The movement chain, above the vitals. ## ## Movement is this game's stated first pillar and chaining mechanics is its ## skill expression, so the chain count is not a debug readout — it is the score ## of the thing the game is about, and it belongs on the HUD at a size that says ## so. It hides at zero, because a chain of nothing is not worth screen space. func _build_chain(into: Container) -> void: _chain_row = HBoxContainer.new() _chain_row.add_theme_constant_override("separation", 8) _chain_row.mouse_filter = Control.MOUSE_FILTER_IGNORE _chain_row.visible = false into.add_child(_chain_row) _chain_num = Label.new() _chain_num.text = "x0" _chain_num.add_theme_font_size_override("font_size", 40) _chain_num.add_theme_color_override("font_color", UITheme.VOLT) _chain_num.add_theme_color_override("font_outline_color", UITheme.INK) _chain_num.add_theme_constant_override("outline_size", 9) _chain_num.pivot_offset = Vector2(20, 20) _chain_row.add_child(_chain_num) _chain_bonus = Label.new() _chain_bonus.text = "" _chain_bonus.add_theme_font_size_override("font_size", 22) _chain_bonus.add_theme_color_override("font_color", UITheme.PAPAYA) _chain_bonus.add_theme_color_override("font_outline_color", UITheme.INK) _chain_bonus.add_theme_constant_override("outline_size", 7) _chain_bonus.vertical_alignment = VERTICAL_ALIGNMENT_BOTTOM _chain_row.add_child(_chain_bonus) ## FPS / speed / state, top left, and only when a setting asks for them. ## ## Restyled rather than deleted: they are genuinely useful, and in a game about ## momentum the speed readout is arguably gameplay. What they are not is a reason ## to have neon green on pure black in the corner of an ink-drawn game, which is ## what three copies of this used to be. func _build_debug() -> void: var margin := MarginContainer.new() margin.set_anchors_and_offsets_preset(Control.PRESET_TOP_LEFT) margin.add_theme_constant_override("margin_left", 18) margin.add_theme_constant_override("margin_top", 14) margin.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(margin) _debug_box = VBoxContainer.new() _debug_box.add_theme_constant_override("separation", 0) _debug_box.mouse_filter = Control.MOUSE_FILTER_IGNORE margin.add_child(_debug_box) _fps_line = UITheme.caption("", 22) _fps_line.add_theme_color_override("font_color", UITheme.VOLT) _debug_box.add_child(_fps_line) _speed_line = UITheme.caption("", 22) _speed_line.add_theme_color_override("font_color", UITheme.CYAN) _debug_box.add_child(_speed_line) _state_line = UITheme.caption("", 18) _debug_box.add_child(_state_line) func _build_death_screen() -> void: death_screen = Control.new() death_screen.name = "DeathScreen" death_screen.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) death_screen.mouse_filter = Control.MOUSE_FILTER_IGNORE death_screen.visible = false add_child(death_screen) # Ink wash rather than plain black: the whole game's darkness is a violet # near-black, and a neutral 70% black over it reads as a bug. var wash := ColorRect.new() wash.color = Color(UITheme.INK.r, UITheme.INK.g, UITheme.INK.b, 0.78) wash.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) wash.mouse_filter = Control.MOUSE_FILTER_IGNORE death_screen.add_child(wash) var center := CenterContainer.new() center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) center.mouse_filter = Control.MOUSE_FILTER_IGNORE death_screen.add_child(center) var col := VBoxContainer.new() col.alignment = BoxContainer.ALIGNMENT_CENTER col.add_theme_constant_override("separation", 10) center.add_child(col) var title := UITheme.title("DOWNED", 104, -3.0) title.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER col.add_child(title) var rule := UITheme.divider(0.5) rule.custom_minimum_size = Vector2(520, 18) col.add_child(rule) var prompt := UITheme.heading("PRESS FIRE TO REDEPLOY", 30) prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER col.add_child(prompt) var tw := create_tween().set_loops() tw.set_trans(Tween.TRANS_SINE) tw.tween_property(prompt, "modulate:a", 0.25, 0.7) tw.tween_property(prompt, "modulate:a", 1.0, 0.7) # ── Per-frame ──────────────────────────────────────────────────────────────── func _process(delta: float) -> void: if not is_instance_valid(player): return _update_weapon(delta) _update_crosshair(delta) _update_movement() _update_debug() ## Chain count and ability cooldowns, both read off the movement state machine. func _update_movement() -> void: var sm := _state_machine() if sm == null: return var chain := int(sm.chain_count) if "chain_count" in sm else 0 _chain_row.visible = chain > 0 if chain > 0: _chain_num.text = "x%d" % chain var bonus: float = float(sm.current_chain_bonus) if "current_chain_bonus" in sm else 0.0 _chain_bonus.text = "+%d%% SPEED" % int(round(bonus * 100.0)) # Each new link punches the number up and lets it settle. Momentum is the # thing this game rewards, so extending a chain should feel like landing # something rather than like a counter incrementing. if chain > _shown_chain: var tw := create_tween() tw.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_OUT) _chain_num.scale = Vector2(1.35, 1.35) tw.tween_property(_chain_num, "scale", Vector2.ONE, 0.28) _shown_chain = chain var state := String(sm.current_state) if "current_state" in sm else "" var shooting: bool = "is_grapple_shooting" in sm and sm.is_grapple_shooting _grapple_chip.active = state == "grapple" or shooting if sm.has_method("get_dash_cooldown_remaining"): var rem: float = sm.get_dash_cooldown_remaining() # Normalised against the params' own cooldown, so retuning the dash # retunes the readout with it instead of leaving the wipe lying. var total := 1.0 if "params" in player and player.params and "dash_cooldown" in player.params: total = maxf(float(player.params.dash_cooldown), 0.001) _dash_chip.cooldown = clampf(rem / total, 0.0, 1.0) func _update_debug() -> void: # Resolved through the tree rather than by the autoload's global identifier. # A tool script launched with `-s` compiles its dependencies BEFORE autoloads # are registered, so naming `SettingsManager` directly makes this whole file # fail to compile under debug/hud_layout_check.gd — which is precisely the # harness that has to be able to load it. var settings := _settings() if settings == null: return var sm := _state_machine() var show_debug: bool = settings.show_debug_ui _fps_line.visible = settings.show_fps if _fps_line.visible: _fps_line.text = "FPS %d" % Engine.get_frames_per_second() _speed_line.visible = settings.show_movement_speed or show_debug if _speed_line.visible: var v: Vector3 = player.velocity if "velocity" in player else Vector3.ZERO var h := Vector2(v.x, v.z).length() _speed_line.text = "%.1f m/s" % h if not show_debug \ else "%.1f m/s (total %.1f)" % [h, v.length()] _state_line.visible = show_debug and sm != null if _state_line.visible: _state_line.text = String(sm.current_state).to_upper() ## Ammo, weapon name and grenades, read off whatever is in the player's hands. ## ## Read rather than pushed, because the weapon is the authority on its own ammo ## and there are eleven weapon scripts. A HUD that had to be notified would mean ## eleven places to forget to notify it. func _update_weapon(_delta: float) -> void: var w := _active_weapon() if w == null: _ammo_card.visible = false return _ammo_card.visible = true # Named by the weapon, or derived from its script if it forgot to say. A HUD # that silently shows a blank where the weapon's name goes is worse than one # that shows a slightly ugly name. if "weapon_name" in w and String(w.weapon_name) != "": _weapon_name.text = String(w.weapon_name).to_upper() else: var src: Script = w.get_script() _weapon_name.text = src.resource_path.get_file().get_basename() \ .replace("_", " ").to_upper() if src else "WEAPON" # The shotgun counts shells, everything else counts rounds. Both are "how # many more times can I pull the trigger", which is the only question the # number answers. var now := -1 var cap := -1 if "current_ammo" in w: now = int(w.current_ammo) cap = int(w.max_ammo) if "max_ammo" in w else -1 elif "shells" in w: now = int(w.shells) cap = int(w.max_shells) if "max_shells" in w else -1 if now < 0: # A melee weapon has no count. Showing "0" would read as empty. _ammo_num.text = "—" _ammo_max.text = "" else: _ammo_num.text = str(now) _ammo_max.text = "/ %d" % cap if cap > 0 else "" var frac: float = float(now) / float(maxi(cap, 1)) var reloading: bool = "reloading" in w and w.reloading var col: Color = UITheme.PAPER if now == 0: col = UITheme.MAGENTA elif frac <= LOW_AMMO: col = UITheme.VOLT if reloading: col = UITheme.PAPER_DIM _ammo_num.add_theme_color_override("font_color", col) # A shot fired blooms the reticle. Detected from the count dropping # rather than from a signal, for the same reason the count is read. if _shown_ammo >= 0 and now < _shown_ammo: _fire_bloom = 1.0 _shown_ammo = now _update_grenades() ## Grenades as pips, rebuilt only when the count changes. ## ## Built once and then shown/hidden rather than freed, because `queue_free` is ## deferred: a loop that frees down to a target count sees the same child count ## on the next iteration and frees the whole row. func _update_grenades() -> void: var count := int(player.grenades) if "grenades" in player else 0 while _grenade_row.get_child_count() < maxi(count, _grenade_pips): var pip := Panel.new() pip.custom_minimum_size = Vector2(17, 17) pip.mouse_filter = Control.MOUSE_FILTER_IGNORE # A filled chip with the theme's ink edge, so a 17 px pip still reads # against a bright skybox. pip.add_theme_stylebox_override("panel", UITheme.row(UITheme.CYAN)) _grenade_row.add_child(pip) for i in _grenade_row.get_child_count(): (_grenade_row.get_child(i) as Control).visible = i < count ## The most pips ever needed. Grenades only ever go down during a life and back ## up on respawn, so this is the starting count. const _grenade_pips := 3 ## Reticle bloom and the ADS blend. ## ## Sources are combined with `max`, not by adding: a sprinting player who fires ## should see the reticle at its bloomed size, not at twice it. func _update_crosshair(delta: float) -> void: _fire_bloom = maxf(_fire_bloom - delta * FIRE_RECOVER, 0.0) var speed := 0.0 if "velocity" in player: var v: Vector3 = player.velocity speed = Vector2(v.x, v.z).length() var move := clampf(speed / BLOOM_SPEED, 0.0, 1.0) var state := String(player.synced_movement_state) if "synced_movement_state" in player else "" # Airborne is the least accurate a player can be, and a reticle that says so # is what stops mid-air spraying from feeling arbitrary. var air := 0.75 if state in ["air", "dash", "grapple"] else 0.0 var want: float = maxf(maxf(move, air), _fire_bloom) _spread = lerpf(_spread, want, 1.0 - exp(-SPREAD_SMOOTH * delta)) crosshair.spread = _spread var ads := 0.0 if "synced_is_ads" in player and player.synced_is_ads: ads = 1.0 crosshair.ads = lerpf(crosshair.ads, ads, 1.0 - exp(-14.0 * delta)) # The reload ring reads the weapon directly, same as the ammo count. var w := _active_weapon() if w and "reloading" in w and w.reloading and "reload_timer" in w \ and "reload_time" in w and w.reload_time > 0.0: reload_ring.progress = 1.0 - (w.reload_timer / w.reload_time) else: reload_ring.progress = 0.0 ## The settings autoload, or null when the HUD is being exercised outside a ## running game. See the note in `_update_debug`. func _settings() -> Node: if not is_instance_valid(_settings_node): _settings_node = get_tree().root.get_node_or_null("SettingsManager") return _settings_node if is_instance_valid(_settings_node) else null var _settings_node: Node = null ## The player's movement state machine, or null. Cached, since it is asked for ## several times a frame and never moves. func _state_machine() -> Node: if not is_instance_valid(_machine): _machine = player.get_node_or_null("MovementStateMachine") return _machine if is_instance_valid(_machine) else null ## Whatever weapon is in the player's hands, or null. func _active_weapon() -> Node: if not is_instance_valid(_wman): var cam = player.get("camera") if "camera" in player else null if is_instance_valid(cam): _wman = cam.get_node_or_null("WeaponManager") if not is_instance_valid(_wman): return null var slot = _wman.get("active_slot") if slot == null or not _wman.weapons.has(slot): return null var w = _wman.weapons[slot] return w if is_instance_valid(w) else null