extends Node2D ## Root of the playable client scene. Wires the view and HUD to whatever client ## runtime [Net] currently has, and survives that runtime being null (during ## connection) or replaced (on reconnect). @onready var world_view: Node2D = $WorldView @onready var hud: CanvasLayer = $HUD @onready var menu: CanvasLayer = $GameMenu @onready var sfx: Node = $Sfx @onready var characters: CanvasLayer = $CharacterSelect @onready var upgrades: CanvasLayer = $UpgradeScreen @onready var settings: CanvasLayer = $SettingsScreen var _bound: ClientRuntime = null ## Opened deliberately from the menu, as opposed to forced open by having no ## character to play. var _roster_open: bool = false ## The quartermaster panel. Opened by walking to the NPC and pressing interact, ## and closed the moment you walk away -- the server refuses a choice made from ## anywhere else, so leaving the panel open at a distance would only offer a ## button that gets rejected. var _upgrades_open: bool = false func _ready() -> void: _recentre() get_viewport().size_changed.connect(_recentre) menu.return_to_hub_requested.connect(_on_return_to_hub) menu.disconnect_requested.connect(_on_disconnect) menu.characters_requested.connect(func() -> void: _roster_open = true) hud.respawn_pressed.connect(_on_respawn_pressed) characters.select_requested.connect(func(id: String) -> void: _roster_open = false Net.select_character(id)) characters.create_requested.connect(func(n: String) -> void: Net.create_character(n)) characters.closed.connect(func() -> void: _roster_open = false) upgrades.closed.connect(func() -> void: _upgrades_open = false) menu.settings_requested.connect(func() -> void: settings.open()) settings.closed.connect(func() -> void: settings.visible = false) upgrades.choose_requested.connect(func(i: int) -> void: Net.choose_upgrade(i)) var _screen_centre := Vector2.ZERO func _recentre() -> void: _screen_centre = get_viewport_rect().size * 0.5 ## Scroll the world so the local player stays centred. Dungeons are larger than ## the viewport now, so a fixed camera would simply lose the player off-screen. func _follow_camera() -> void: var focus := Vector2.ZERO if _bound != null: focus = _bound.predicted_pos world_view.position = _screen_centre - focus if _bound != null: # The client converts cursor position to world space with this, so it # has to come from the transform actually used to draw, not from a # second assumption about where the camera is. _bound.camera_offset = world_view.position func _process(_delta: float) -> void: if Net.client != _bound: _bound = Net.client if _bound != null and not _bound.local_hit.is_connected(_on_local_hit): _bound.local_hit.connect(_on_local_hit) _bound.shot_fired.connect(func() -> void: sfx.play(Art.SFX_SHOOT, -14.0)) _bound.enemy_died.connect(func() -> void: sfx.play(Art.SFX_ENEMY_DEATH, -8.0)) _bound.boss_died.connect(func() -> void: sfx.play(Art.SFX_BOSS_DEATH, -2.0)) # Item feedback rides the same rule as every other sound here: it # plays because a server event arrived, never because the client # guessed a pickup succeeded. _bound.item_picked_up.connect(func(_i: StringName) -> void: sfx.play(Art.SFX_ENEMY_DEATH, -16.0)) _bound.item_used.connect(func(_i: StringName) -> void: sfx.play(Art.SFX_SHOOT, -10.0)) _bound.characters_changed.connect(_refresh_characters) _bound.upgrades_changed.connect(_refresh_upgrades) _refresh_upgrades() _bound.select_failed.connect(func(why: String) -> void: characters.set_status(why) upgrades.set_status(why)) _refresh_characters() # The roster screen is shown exactly when there is nothing to play: first # login, or after the last living character died. if _bound != null: # Forced open when there is nothing to play; opened deliberately from # the menu otherwise. Never while in a dungeon -- the server refuses # swaps there, so offering the screen would only invite a refusal. var forced := _bound.needs_character() if _bound.instance_kind != Protocol.InstanceKind.LOBBY: _roster_open = false characters.visible = forced or _roster_open characters.set_dismissible(not forced) if _bound != null and _upgrades_open and not _bound.at_upgrade_npc(): _upgrades_open = false upgrades.visible = _upgrades_open _follow_camera() menu.set_in_dungeon(_bound != null and _bound.instance_kind == Protocol.InstanceKind.DUNGEON) func _unhandled_input(event: InputEvent) -> void: if not event.is_action_pressed("interact"): return if _bound == null or not _bound.at_upgrade_npc(): return # Interact is also pick-up. Loot wins, exactly as it does on the server, so # the key never does one thing here and another there. if not _bound.loot_in_reach().is_empty(): return _upgrades_open = not _upgrades_open _refresh_upgrades() func _refresh_upgrades() -> void: if _bound != null: upgrades.refresh(_bound.upgrades_pending, _bound.upgrade_offer, _bound.upgrades_taken) ## Routed through the same held-escape channel the F key uses, rather than a ## direct "teleport me" message -- the server has no such message, and adding ## one would hand clients an instant, uninterruptible exit. func _on_return_to_hub() -> void: if _bound != null: _bound.request_escape = true func _on_disconnect() -> void: Net.shutdown() ## Sets the intent bit; the server decides whether the lockout has expired. func _refresh_characters() -> void: if _bound != null: characters.refresh(_bound.characters, _bound.selected_character) func _on_respawn_pressed() -> void: if _bound != null: _bound.request_respawn = true func _on_local_hit(_damage: int) -> void: hud.flash_hit() sfx.play(Art.SFX_PLAYER_HIT, -4.0)