extends CanvasLayer ## In-game HUD: crosshair, interaction prompt, hotbar, toasts, and the ## inventory screen. Rendering only: it reads player state and forwards ## clicks to Actions. const UITheme = preload("res://scripts/ui/theme.gd") const SlotScript = preload("res://scripts/ui/slot.gd") const InventoryScreen = preload("res://scripts/ui/inventory_screen.gd") const PauseMenu = preload("res://scripts/ui/pause_menu.gd") var player: CharacterBody3D var _prompt: Label var _held_name: Label var _hotbar: Array = [] var _toasts: VBoxContainer var _screen: Control var _menu: Control var _bars: Dictionary = {} var _status: Label var _clock: Label func bind(p: CharacterBody3D) -> void: player = p layer = 5 var root := Control.new() root.set_anchors_preset(Control.PRESET_FULL_RECT) root.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(root) # Crosshair. var dot := ColorRect.new() dot.color = Color(1, 1, 1, 0.8) dot.size = Vector2(4, 4) dot.set_anchors_preset(Control.PRESET_CENTER) dot.position -= Vector2(2, 2) dot.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(dot) _prompt = UITheme.label("", 18) _prompt.set_anchors_and_offsets_preset(Control.PRESET_CENTER) _prompt.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _prompt.grow_horizontal = Control.GROW_DIRECTION_BOTH _prompt.offset_top = 28 _prompt.offset_bottom = 56 root.add_child(_prompt) # Hotbar. var bar := HBoxContainer.new() bar.add_theme_constant_override("separation", 6) bar.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) bar.grow_horizontal = Control.GROW_DIRECTION_BOTH bar.grow_vertical = Control.GROW_DIRECTION_BEGIN bar.offset_bottom = -18 bar.mouse_filter = Control.MOUSE_FILTER_IGNORE for i in range(8): var s: Panel = SlotScript.new(i, 64, str(i + 1)) s.mouse_filter = Control.MOUSE_FILTER_IGNORE bar.add_child(s) _hotbar.append(s) root.add_child(bar) _held_name = UITheme.label("", 15, UITheme.TEXT_DIM) _held_name.set_anchors_and_offsets_preset(Control.PRESET_CENTER_BOTTOM) _held_name.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER _held_name.grow_horizontal = Control.GROW_DIRECTION_BOTH _held_name.offset_top = -112 _held_name.offset_bottom = -90 root.add_child(_held_name) _toasts = VBoxContainer.new() _toasts.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) _toasts.grow_horizontal = Control.GROW_DIRECTION_BEGIN _toasts.grow_vertical = Control.GROW_DIRECTION_BEGIN _toasts.offset_right = -24 _toasts.offset_bottom = -110 _toasts.alignment = BoxContainer.ALIGNMENT_END _toasts.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(_toasts) _build_stats(root) _screen = InventoryScreen.new() root.add_child(_screen) _screen.bind(player) _menu = PauseMenu.new() root.add_child(_menu) _menu.bind(player) process_mode = Node.PROCESS_MODE_ALWAYS # so ESC works while paused _screen.process_mode = Node.PROCESS_MODE_PAUSABLE player.inventory.changed.connect(_refresh) player.selected_changed.connect(_refresh) player.inventory_requested.connect(_on_inventory_requested) player.hands.prompt_changed.connect(func(t): _prompt.text = t) Actions.notify.connect(toast) ItemDB.icons_ready.connect(_refresh) _refresh() ## Bars bottom-left (health, stamina, food, warmth), status tags above them, ## clock top-right. func _build_stats(root: Control) -> void: var box := VBoxContainer.new() box.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_LEFT) box.grow_vertical = Control.GROW_DIRECTION_BEGIN box.offset_left = 22 box.offset_bottom = -22 box.add_theme_constant_override("separation", 5) box.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(box) _status = UITheme.label("", 15, UITheme.TEXT) box.add_child(_status) for spec in [["health", "Health", Color(0.85, 0.22, 0.2)], ["stamina", "Stamina", Color(0.9, 0.78, 0.25)], ["hunger", "Food", Color(0.85, 0.5, 0.2)], ["warmth", "Warmth", Color(0.95, 0.55, 0.3)]]: var row := HBoxContainer.new() row.add_theme_constant_override("separation", 8) row.mouse_filter = Control.MOUSE_FILTER_IGNORE var name_l := UITheme.label(spec[1], 14, UITheme.TEXT_DIM) name_l.custom_minimum_size = Vector2(62, 0) row.add_child(name_l) var bar := ProgressBar.new() bar.custom_minimum_size = Vector2(190, 14) bar.show_percentage = false bar.max_value = 100.0 bar.mouse_filter = Control.MOUSE_FILTER_IGNORE bar.add_theme_stylebox_override("background", UITheme.box(Color(0, 0, 0, 0.55), UITheme.SLOT_BORDER, 1, 4, 0)) bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 4, 0)) row.add_child(bar) box.add_child(row) _bars[spec[0]] = bar _clock = UITheme.label("", 18) _clock.set_anchors_and_offsets_preset(Control.PRESET_TOP_RIGHT) _clock.grow_horizontal = Control.GROW_DIRECTION_BEGIN _clock.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT _clock.offset_right = -22 _clock.offset_top = 14 root.add_child(_clock) func _process(_d: float) -> void: var s = player.survival for k in _bars: _bars[k].value = s.get(k) # Warmth bar shifts from icy blue (cold) to orange (warm). var w: float = s.warmth / 100.0 var wc := Color(0.35, 0.6, 0.95).lerp(Color(0.95, 0.55, 0.3), w) (_bars["warmth"].get_theme_stylebox("fill") as StyleBoxFlat).bg_color = wc _status.text = " ".join(s.statuses) var dn = player.get_parent().day_night if dn != null: _clock.text = dn.clock_text() ## Closes the inventory / menu (e.g. on death). func close_screens() -> void: if _screen.is_open(): _screen.close() func _on_inventory_requested(container: Node) -> void: if not _screen.is_open(): _screen.open(container) ## ESC: close the inventory if it's open, otherwise toggle the pause menu. func _input(event: InputEvent) -> void: if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_ESCAPE: if _screen.is_open(): _screen.close() elif _menu.is_open(): _menu.back() else: _menu.open() get_viewport().set_input_as_handled() func _unhandled_input(event: InputEvent) -> void: if _menu.is_open(): return if event.is_action_pressed("inventory") and not _screen.is_open(): _screen.open(null) get_viewport().set_input_as_handled() func _refresh() -> void: for i in range(_hotbar.size()): _hotbar[i].set_stack(player.inventory.get_slot(i)) _hotbar[i].set_selected(i == player.selected) var id: String = player.held_item_id() _held_name.text = ItemDB.item_name(id) if not id.is_empty() else "" func toast(text: String) -> void: # Merge repeated "+N Item" toasts so chopping doesn't spam the corner. for c in _toasts.get_children(): if c.get_meta("base", "") == text: c.modulate.a = 1.0 return var l := UITheme.label(text, 17) l.horizontal_alignment = HORIZONTAL_ALIGNMENT_RIGHT l.set_meta("base", text) _toasts.add_child(l) while _toasts.get_child_count() > 6: _toasts.get_child(0).free() var tw := l.create_tween() tw.tween_interval(2.2) tw.tween_property(l, "modulate:a", 0.0, 0.8) tw.tween_callback(l.queue_free)