extends CanvasLayer @onready var hp_bar: ProgressBar = %HpBar @onready var hp_label: Label = %HpLabel @onready var shield_bar: ProgressBar = %ShieldBar @onready var shield_label: Label = %ShieldLabel @onready var xp_bar: ProgressBar = %XpBar @onready var level_label: Label = %LevelLabel @onready var wave_label: Label = %WaveLabel @onready var timer_label: Label = %WaveTimer @onready var village_bar: ProgressBar = %VillageBar @onready var role_icon: Panel = %RoleIcon @onready var role_label: Label = %RoleLabel @onready var ability_box: HBoxContainer = %AbilityBox @onready var death_overlay: Control = %DeathOverlay @onready var death_label: Label = %DeathLabel @onready var chat_log: RichTextLabel = %ChatLog @onready var chat_input: LineEdit = %ChatInput @onready var inventory_panel: Control = %InventoryPanel @onready var inventory_list: VBoxContainer = %InventoryList @onready var crafting_panel: Control = %CraftingPanel @onready var crafting_list: VBoxContainer = %CraftingList @onready var build_panel: Control = %BuildPanel @onready var build_list: HBoxContainer = %BuildList @onready var dialog_panel: Control = %DialogPanel @onready var dialog_npc: Label = %DialogNpc @onready var dialog_log: RichTextLabel = %DialogLog @onready var dialog_input: LineEdit = %DialogInput @onready var map_panel: Control = %MapPanel @onready var map_canvas: Control = %MapCanvas @onready var minimap_canvas: Control = %MinimapCanvas @onready var pause_panel: Control = %PausePanel @onready var game_over_overlay: Control = %GameOverOverlay @onready var ability_buttons: Array = [] var local_player: Node = null var dialog_npc_node: Node = null var build_selected: int = 0 var build_rotation: float = 0.0 var build_preview: MeshInstance3D = null var build_active: bool = false var _minimap_accum: float = 0.0 func _ready() -> void: add_to_group("dialog_ui") EventBus.health_changed.connect(_on_health_changed) EventBus.shield_changed.connect(_on_shield_changed) EventBus.cooldown_tick.connect(_on_cooldown_tick) EventBus.role_changed.connect(_on_role_changed) EventBus.entity_died.connect(_on_entity_died) EventBus.entity_respawned.connect(_on_respawned) EventBus.wave_started.connect(_on_wave_started) EventBus.wave_timer_tick.connect(_on_wave_tick) EventBus.village_damaged.connect(_on_village_damaged) EventBus.village_destroyed.connect(_on_village_destroyed) EventBus.invasion_started.connect(_on_invasion_started) EventBus.xp_gained.connect(_on_xp_gained) EventBus.level_up.connect(_on_level_up) EventBus.dialog_opened.connect(_on_dialog_opened) EventBus.inventory_changed.connect(_on_inventory_changed) EventBus.chat_message.connect(_on_chat) chat_input.text_submitted.connect(_on_chat_submitted) dialog_input.text_submitted.connect(_on_dialog_submitted) death_overlay.visible = false inventory_panel.visible = false crafting_panel.visible = false build_panel.visible = false dialog_panel.visible = false map_panel.visible = false pause_panel.visible = false game_over_overlay.visible = false set_process(true) _wire_ability_buttons() call_deferred("_populate_build_list") func _process(_delta: float) -> void: if local_player == null: local_player = _find_local_player() if local_player: var role: int = int(Stats.get_stat(local_player, "role", GameState.ROLE_DAMAGE)) _on_role_changed(local_player, role) _refresh_vitals() if build_active: _update_build_preview() _update_minimap() func _is_typing() -> bool: var f := get_viewport().gui_get_focus_owner() return f is LineEdit or f is TextEdit func _unhandled_input(event: InputEvent) -> void: if _is_typing(): if event.is_action_pressed("pause"): var f := get_viewport().gui_get_focus_owner() if f is LineEdit: (f as LineEdit).release_focus() _capture_ui(_any_panel_visible()) get_viewport().set_input_as_handled() return if event.is_action_pressed("inventory"): _toggle_panel(inventory_panel) _refresh_inventory() elif event.is_action_pressed("crafting"): _toggle_panel(crafting_panel) _refresh_crafting() elif event.is_action_pressed("build_mode"): _toggle_build_mode() elif event.is_action_pressed("map"): _toggle_panel(map_panel) elif event.is_action_pressed("chat"): chat_input.grab_focus() _capture_ui(true) elif event.is_action_pressed("pause"): if dialog_panel.visible: dialog_panel.visible = false _capture_ui(false) elif build_active: _toggle_build_mode() elif inventory_panel.visible or crafting_panel.visible or map_panel.visible: inventory_panel.visible = false crafting_panel.visible = false map_panel.visible = false _capture_ui(false) else: _toggle_pause() elif build_active: if event.is_action_pressed("rotate_build"): build_rotation = wrapf(build_rotation + PI * 0.5, 0.0, TAU) elif event.is_action_pressed("ability_1"): _select_build(0) elif event.is_action_pressed("ability_2"): _select_build(1) elif event.is_action_pressed("ability_3"): _select_build(2) elif event.is_action_pressed("ability_4"): _select_build(3) elif event is InputEventMouseButton and event.pressed: if event.button_index == MOUSE_BUTTON_LEFT: _try_place() elif event.button_index == MOUSE_BUTTON_MIDDLE: _try_remove() func _wire_ability_buttons() -> void: for c in ability_box.get_children(): if c is Button: ability_buttons.append(c) func _toggle_panel(panel: Control) -> void: panel.visible = not panel.visible _capture_ui(_any_panel_visible()) func _any_panel_visible() -> bool: return inventory_panel.visible or crafting_panel.visible or dialog_panel.visible or pause_panel.visible func _capture_ui(v: bool) -> void: if local_player and local_player.has_method("set_ui_capturing"): local_player.set_ui_capturing(v) func _find_local_player() -> Node: for p in get_tree().get_nodes_in_group("player"): if p.is_multiplayer_authority(): return p return null func _refresh_vitals() -> void: if local_player == null: return var hp: float = float(Stats.get_stat(local_player, "health", 0.0)) var max_hp: float = float(Stats.get_stat(local_player, "max_health", 1.0)) _on_health_changed(local_player, hp, max_hp) var shield: float = float(Stats.get_stat(local_player, "shield", 0.0)) var max_shield: float = float(Stats.get_stat(local_player, "max_shield", 0.0)) _on_shield_changed(local_player, shield, max_shield) var xp: float = float(Stats.get_stat(local_player, "xp", 0.0)) var to_next: float = float(Stats.get_stat(local_player, "xp_to_next", 50.0)) xp_bar.max_value = to_next xp_bar.value = xp level_label.text = "Lv %d" % int(Stats.get_stat(local_player, "level", 1)) func _on_health_changed(entity: Node, current: float, max: float) -> void: if entity != local_player: return hp_bar.max_value = max hp_bar.value = current hp_label.text = "%d / %d" % [int(current), int(max)] func _on_shield_changed(entity: Node, current: float, max: float) -> void: if entity != local_player: return shield_bar.max_value = max if max > 0 else 1 shield_bar.value = current shield_label.text = "%d / %d" % [int(current), int(max)] func _on_cooldown_tick(entity: Node, cds: PackedFloat32Array, _max_cds: PackedFloat32Array, gcd: float) -> void: if entity != local_player: return for i in range(min(ability_buttons.size(), cds.size())): var btn: Button = ability_buttons[i] if cds[i] > 0.0: btn.text = "%d\n%.1f" % [i + 1, cds[i]] btn.disabled = true elif gcd > 0.0: btn.text = "%d\nGCD" % (i + 1) btn.disabled = true else: btn.text = "%d" % (i + 1) btn.disabled = false func _on_role_changed(player: Node, role: int) -> void: if player != local_player and player != _find_local_player(): return if local_player == null: local_player = player match role: GameState.ROLE_TANK: role_label.text = "T" role_icon.modulate = Color(0.3, 0.5, 0.95) GameState.ROLE_DAMAGE: role_label.text = "D" role_icon.modulate = Color(0.95, 0.3, 0.3) GameState.ROLE_HEALER: role_label.text = "H" role_icon.modulate = Color(0.4, 0.85, 0.4) func _on_entity_died(entity: Node) -> void: if entity != local_player: return death_overlay.visible = true death_label.text = "Respawning..." func _on_respawned(entity: Node) -> void: if entity != local_player: return death_overlay.visible = false func _on_wave_started(wave: int) -> void: wave_label.text = "Wave %d" % wave func _on_wave_tick(seconds: float) -> void: var m := int(seconds) / 60 var s := int(seconds) % 60 timer_label.text = "%02d:%02d" % [m, s] func _on_village_damaged(current: float, max: float) -> void: village_bar.max_value = max village_bar.value = current func _on_village_destroyed() -> void: game_over_overlay.visible = true func _on_invasion_started() -> void: timer_label.modulate = Color(1.0, 0.4, 0.3) func _on_xp_gained(player: Node, _amount: float) -> void: if player != local_player: return var xp: float = float(Stats.get_stat(player, "xp", 0.0)) var to_next: float = float(Stats.get_stat(player, "xp_to_next", 50.0)) xp_bar.max_value = to_next xp_bar.value = xp func _on_level_up(player: Node, new_level: int) -> void: if player != local_player: return level_label.text = "Lv %d" % new_level _refresh_vitals() func _on_dialog_opened(player: Node, npc: Node) -> void: if player != local_player: return dialog_panel.visible = true dialog_npc_node = npc dialog_npc.text = npc.profile.display_name dialog_log.text = "[i]" + npc.profile.greeting + "[/i]\n" dialog_input.text = "" dialog_input.grab_focus() _capture_ui(true) func _on_dialog_submitted(text: String) -> void: if dialog_npc_node == null: return var dialog_sys: Node = _find_system("DialogSystem") if dialog_sys == null: return dialog_log.append_text("[b]Du:[/b] " + text + "\n[i]...[/i]\n") dialog_input.text = "" dialog_sys.ask(dialog_npc_node, local_player, text) func show_answer(text: String) -> void: if dialog_npc_node == null: return dialog_log.text = dialog_log.text.replace("[i]...[/i]\n", "") dialog_log.append_text("[b]" + dialog_npc_node.profile.display_name + ":[/b] " + text + "\n") func _on_inventory_changed(player: Node) -> void: if player != local_player: return _refresh_inventory() func _refresh_inventory() -> void: for c in inventory_list.get_children(): c.queue_free() if local_player == null: return var inv_sys: Node = _find_system("InventorySystem") if inv_sys == null: return var inv: Dictionary = inv_sys.get_inventory(local_player) if inv.is_empty(): var lbl := Label.new() lbl.text = "(empty)" inventory_list.add_child(lbl) return for k in inv.keys(): var lbl := Label.new() lbl.text = "%s: %d" % [str(k), inv[k]] inventory_list.add_child(lbl) func _refresh_crafting() -> void: for c in crafting_list.get_children(): c.queue_free() if local_player == null: return var c_sys: Node = _find_system("CraftingSystem") var inv_sys: Node = _find_system("InventorySystem") if c_sys == null or inv_sys == null: return for r in c_sys.get_recipes(): var btn := Button.new() var inputs_str: String = "" for k in r.inputs.keys(): inputs_str += "%s x%d " % [str(k), r.inputs[k]] btn.text = "%s (%s)" % [r.name, inputs_str.strip_edges()] btn.disabled = not c_sys.can_craft(local_player, r) btn.pressed.connect(func(): c_sys.craft(local_player, r.id); _refresh_crafting()) crafting_list.add_child(btn) func _populate_build_list() -> void: for c in build_list.get_children(): c.queue_free() var b_sys: Node = _find_system("BuildingSystem") if b_sys == null: return var bps: Array = b_sys.get_blueprints() for i in range(bps.size()): var btn := Button.new() btn.text = "%d %s\n%s x%d" % [i + 1, bps[i].name, str(bps[i].material), bps[i].cost] btn.toggle_mode = true btn.button_pressed = (i == 0) btn.pressed.connect(func(): _select_build(i)) build_list.add_child(btn) func _select_build(idx: int) -> void: build_selected = idx var btns := build_list.get_children() for i in range(btns.size()): if btns[i] is Button: (btns[i] as Button).button_pressed = (i == idx) if build_preview: _update_preview_mesh() func _toggle_build_mode() -> void: build_active = not build_active build_panel.visible = build_active if local_player and local_player.has_method("set_build_mode"): local_player.set_build_mode(build_active) if build_active: _create_build_preview() elif build_preview: build_preview.queue_free() build_preview = null func _create_build_preview() -> void: if build_preview: build_preview.queue_free() var world: Node = get_tree().current_scene if world == null: return build_preview = MeshInstance3D.new() build_preview.cast_shadow = MeshInstance3D.SHADOW_CASTING_SETTING_OFF world.add_child(build_preview) _update_preview_mesh() func _update_preview_mesh() -> void: if build_preview == null: return var b_sys: Node = _find_system("BuildingSystem") if b_sys == null: return var bp: Dictionary = b_sys.get_blueprints()[build_selected] var box := BoxMesh.new() box.size = bp.size build_preview.mesh = box var mat := StandardMaterial3D.new() var c: Color = bp.color c.a = 0.5 mat.albedo_color = c mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA mat.flags_unshaded = true build_preview.material_override = mat func _update_build_preview() -> void: if build_preview == null or local_player == null: return var b_sys: Node = _find_system("BuildingSystem") if b_sys == null: return var pos: Vector3 = _ground_under_cursor() var snapped: Vector3 = b_sys.snap_position(pos) var bp: Dictionary = b_sys.get_blueprints()[build_selected] build_preview.global_position = snapped + Vector3(0, bp.size.y * 0.5, 0) build_preview.rotation.y = build_rotation func _ground_under_cursor() -> Vector3: var cam: Camera3D = local_player.camera if local_player.has_method("get") else null if cam == null: return Vector3.ZERO var mouse := get_viewport().get_mouse_position() var from := cam.project_ray_origin(mouse) var dir := cam.project_ray_normal(mouse) if abs(dir.y) < 0.001: return from var t: float = -from.y / dir.y if t <= 0: return from return from + dir * t func _try_place() -> void: if local_player == null: return var b_sys: Node = _find_system("BuildingSystem") if b_sys == null: return var bps: Array = b_sys.get_blueprints() var bp: Dictionary = bps[build_selected] var pos: Vector3 = _ground_under_cursor() b_sys.place(local_player, bp.id, pos, build_rotation) func _try_remove() -> void: if local_player == null: return var cam: Camera3D = local_player.camera if local_player.has_method("get") else null if cam == null: return var mouse := get_viewport().get_mouse_position() var from := cam.project_ray_origin(mouse) var to := from + cam.project_ray_normal(mouse) * 100.0 var space: PhysicsDirectSpaceState3D = local_player.get_world_3d().direct_space_state var query := PhysicsRayQueryParameters3D.create(from, to) query.collision_mask = 16 var hit: Dictionary = space.intersect_ray(query) if hit.is_empty(): return var node: Node = hit.collider while node and not node.is_in_group("buildings"): node = node.get_parent() if node: var b_sys: Node = _find_system("BuildingSystem") if b_sys: b_sys.remove(local_player, node.get_path()) func _on_chat(_peer_id: int, sender: String, text: String) -> void: chat_log.append_text("[b]%s:[/b] %s\n" % [sender, text]) func _on_chat_submitted(text: String) -> void: var c_sys: Node = _find_system("ChatSystem") if c_sys: c_sys.send(text) chat_input.text = "" chat_input.release_focus() _capture_ui(_any_panel_visible()) func _toggle_pause() -> void: pause_panel.visible = not pause_panel.visible if pause_panel.visible and multiplayer.multiplayer_peer is OfflineMultiplayerPeer: GameState.set_paused(true) else: GameState.set_paused(false) _capture_ui(_any_panel_visible()) func _on_resume_pressed() -> void: pause_panel.visible = false GameState.set_paused(false) _capture_ui(_any_panel_visible()) func _on_quit_pressed() -> void: Net.disconnect_net() GameState.set_paused(false) GameState.change_scene(GameState.SCENE_MAIN_MENU) func _on_game_over_restart() -> void: Net.disconnect_net() GameState.set_paused(false) GameState.change_scene(GameState.SCENE_MAIN_MENU) func _update_minimap() -> void: _minimap_accum += get_process_delta_time() if _minimap_accum < 0.20: return _minimap_accum = 0.0 minimap_canvas.queue_redraw() if map_panel.visible: map_canvas.queue_redraw() func _find_system(name: String) -> Node: var n: Node = get_tree().root.get_node_or_null("World/Systems/" + name) if n == null: n = get_tree().root.get_node_or_null("Dungeon/Systems/" + name) return n