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 _diet_bars: Dictionary = {} var _status: Label var _clock: Label var _hurt: ColorRect ## A red flash at the screen edge when something hits you. func _on_hurt(amount: float, _from: Vector3) -> void: _hurt.color.a = clampf(0.15 + amount / 60.0, 0.15, 0.45) var tw := _hurt.create_tween() tw.tween_property(_hurt, "color:a", 0.0, 0.6) 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) _hurt = ColorRect.new() _hurt.color = Color(0.7, 0.0, 0.0, 0.0) _hurt.set_anchors_preset(Control.PRESET_FULL_RECT) _hurt.mouse_filter = Control.MOUSE_FILTER_IGNORE root.add_child(_hurt) root.move_child(_hurt, 0) player.survival.hurt.connect(_on_hurt) 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 # Diet: three short bars (meat, fish, greens), so a gap shows early. var drow := HBoxContainer.new() drow.add_theme_constant_override("separation", 6) drow.mouse_filter = Control.MOUSE_FILTER_IGNORE var dl := UITheme.label("Diet", 14, UITheme.TEXT_DIM) dl.custom_minimum_size = Vector2(64, 0) drow.add_child(dl) for spec in [["meat", "Meat", Color(0.75, 0.3, 0.25)], ["fish", "Fish", Color(0.35, 0.6, 0.85)], ["plants", "Greens", Color(0.45, 0.75, 0.3)]]: var cell := VBoxContainer.new() cell.add_theme_constant_override("separation", 0) cell.mouse_filter = Control.MOUSE_FILTER_IGNORE var bar := ProgressBar.new() bar.custom_minimum_size = Vector2(58, 8) 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, 3, 0)) bar.add_theme_stylebox_override("fill", UITheme.box(spec[2], spec[2].lightened(0.2), 1, 3, 0)) cell.add_child(bar) cell.add_child(UITheme.label(spec[1], 11, UITheme.TEXT_DIM)) drow.add_child(cell) _diet_bars[spec[0]] = bar box.add_child(drow) _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) for g in _diet_bars: _diet_bars[g].value = s.diet[g] # A group running low blinks, so you notice before it hurts. _diet_bars[g].modulate.a = 0.45 + 0.55 * absf(sin(Time.get_ticks_msec() / 300.0)) if s.diet[g] < s.DIET_LOW else 1.0 # 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: var wx = player.get_parent().weather _clock.text = dn.clock_text() + (" " + wx.label() if wx != null else "") ## Fades to black, runs `mid` (e.g. skipping the night), and fades back in. func fade(mid: Callable) -> void: var black := ColorRect.new() black.color = Color(0, 0, 0, 0) black.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) black.mouse_filter = Control.MOUSE_FILTER_STOP add_child(black) player.ui_open = true var tw := black.create_tween() tw.tween_property(black, "color:a", 1.0, 0.8) tw.tween_callback(mid) tw.tween_interval(0.7) tw.tween_property(black, "color:a", 0.0, 1.0) tw.tween_callback(func(): player.ui_open = false black.queue_free()) ## 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)