UI Redesign — Layout + Theme Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Replace the web-like VBox/HBox layout with a game-feel UI using 9-patch pixel borders, visual indicators (heart sprites, pip dots, HP bars), and a 3-panel bottom bar where relics wrap into a grid instead of overflowing.

Architecture: Extract 9-patch border textures from the existing monochrome_packed.png atlas. Build a new Godot Theme resource using StyleBoxTexture with 9-patch margins for all panels/buttons. Redesign the combat screen layout with distinct framed zones. Refactor the bottom bar into 3 visually separated panels with a GridContainer for relics. Apply the new theme across all screens.

Tech Stack: Godot 4.6, GDScript, existing Kenney 1-bit monochrome_packed.png atlas, existing diablo.ttf font.


File Structure

ActionFileResponsibility
Createassets/theme/game_theme.tresNew master theme with 9-patch StyleBoxTexture for all Control types
Createassets/art/ui/9patch_panel_dark.pngExtracted 9-patch panel border (dark variant)
Createassets/art/ui/9patch_panel_light.pngExtracted 9-patch panel border (light variant)
Createassets/art/ui/9patch_button_normal.pngExtracted 9-patch button (normal state)
Createassets/art/ui/9patch_button_hover.pngExtracted 9-patch button (hover state)
Createassets/art/ui/9patch_button_pressed.pngExtracted 9-patch button (pressed state)
Createassets/art/ui/9patch_button_disabled.pngExtracted 9-patch button (disabled state)
Createassets/art/ui/9patch_slot.pngInventory slot frame (for abilities/items)
Createassets/art/ui/9patch_slot_highlight.pngInventory slot frame highlighted (hover/active)
Createtools/extract_9patch.gdEditor script to extract 9-patch textures from atlas
Createsrc/ui/components/inventory_slot.gdReusable inventory slot component (icon + frame + tooltip + click)
Createsrc/ui/components/inventory_slot.tscnScene for inventory slot
Createsrc/ui/components/stat_pips.gdReusable pip indicator (filled/empty dots for rerolls, mana)
Createsrc/ui/components/heart_bar.gdReusable heart sprite bar (full/empty hearts for lives)
Createsrc/ui/components/hp_bar.gdReusable HP bar with color thresholds (for enemies)
Createsrc/ui/components/bottom_panel.gdBottom bar with 3 framed sections: abilities, items, relics
Createsrc/ui/components/bottom_panel.tscnScene for bottom panel
Modifyassets/scenes/DiceCombat.tscnNew layout structure using new components
Modifysrc/ui/dice_combat_screen.gdRefactored to use new components, remove manual .new() panel building
Modifysrc/core/sprite_map.gdAdd SLOT_SIZE constant, make_slot_icon() factory
Modifyassets/scenes/Shop.tscnApply new theme
Modifysrc/ui/shop_screen.gdUse inventory_slot for shop items, apply new theme
Modifyassets/scenes/DiceReward.tscnApply new theme
Modifysrc/ui/dice_reward_screen.gdUse inventory_slot for reward items
Modifyassets/scenes/SoloMap.tscnApply new theme, heart_bar for lives
Modifysrc/ui/solo_map_screen.gdUse heart_bar, stat_pips
Modifyassets/scenes/Main.tscnApply new theme

Task 1: Extract 9-patch textures from atlas

Files:

  • Create: tools/extract_9patch.gd
  • Create: assets/art/ui/9patch_panel_dark.png
  • Create: assets/art/ui/9patch_panel_light.png
  • Create: assets/art/ui/9patch_button_normal.png
  • Create: assets/art/ui/9patch_button_hover.png
  • Create: assets/art/ui/9patch_button_pressed.png
  • Create: assets/art/ui/9patch_button_disabled.png
  • Create: assets/art/ui/9patch_slot.png
  • Create: assets/art/ui/9patch_slot_highlight.png

The monochrome_packed.png atlas contains UI tiles at 16x16. We need to extract and scale them into usable 9-patch textures. The atlas has panel borders at (768, 304) 16x16 with 6px margins, and button borders at (609, 193) 14x14.

However, the atlas tiles are tiny (14-16px). For a proper game-feel 9-patch, we need larger bordered textures. We'll create these as small pixel-art PNGs (24x24 or 32x32) that scale cleanly with nearest-neighbor filtering.

  • Step 1: Create the 9-patch panel texture (dark variant)

Create a 24x24 pixel-art PNG for the dark panel border. This will be used as a StyleBoxTexture with 9-patch margins of 8px on each side. The center stretches, corners stay fixed.

Since we can't create PNGs from the CLI, we'll use a GDScript tool that runs in-editor to generate these textures from the atlas + pixel manipulation.

Create tools/extract_9patch.gd:

@tool
extends EditorScript
## Run from Editor > Run Script to generate 9-patch textures from monochrome_packed.png.
## Generates panel, button, and slot 9-patch PNGs in assets/art/ui/.

const ATLAS_PATH := "res://assets/art/ui/monochrome_packed.png"
const OUTPUT_DIR := "res://assets/art/ui/"

## Color palette for the game UI
const CLR_BORDER := Color(0.75, 0.7, 0.6)       # Warm light border
const CLR_BORDER_DIM := Color(0.4, 0.35, 0.3)    # Dim border
const CLR_BG_DARK := Color(0.08, 0.06, 0.1)      # Near-black panel bg
const CLR_BG_LIGHT := Color(0.15, 0.12, 0.18)    # Dark purple-gray panel bg
const CLR_BG_SLOT := Color(0.05, 0.04, 0.07)     # Slot interior (darker)
const CLR_HIGHLIGHT := Color(1.0, 0.85, 0.4)     # Gold highlight
const CLR_BTN_FACE := Color(0.2, 0.18, 0.25)     # Button face
const CLR_BTN_HOVER := Color(0.28, 0.25, 0.33)   # Button hover face
const CLR_BTN_PRESS := Color(0.12, 0.1, 0.15)    # Button pressed face
const CLR_BTN_DIS := Color(0.12, 0.1, 0.14, 0.6) # Button disabled face
const CLR_TRANSPARENT := Color(0, 0, 0, 0)


func _run() -> void:
	_make_panel(OUTPUT_DIR + "9patch_panel_dark.png", CLR_BG_DARK, CLR_BORDER_DIM, 24)
	_make_panel(OUTPUT_DIR + "9patch_panel_light.png", CLR_BG_LIGHT, CLR_BORDER, 24)
	_make_button(OUTPUT_DIR + "9patch_button_normal.png", CLR_BTN_FACE, CLR_BORDER, 24)
	_make_button(OUTPUT_DIR + "9patch_button_hover.png", CLR_BTN_HOVER, CLR_HIGHLIGHT, 24)
	_make_button(OUTPUT_DIR + "9patch_button_pressed.png", CLR_BTN_PRESS, CLR_BORDER_DIM, 24)
	_make_button(OUTPUT_DIR + "9patch_button_disabled.png", CLR_BTN_DIS, CLR_BORDER_DIM, 24)
	_make_slot(OUTPUT_DIR + "9patch_slot.png", CLR_BG_SLOT, CLR_BORDER_DIM, 24)
	_make_slot(OUTPUT_DIR + "9patch_slot_highlight.png", CLR_BG_SLOT, CLR_HIGHLIGHT, 24)
	print("9-patch textures generated in ", OUTPUT_DIR)


func _make_panel(path: String, bg: Color, border: Color, sz: int) -> void:
	## 2px border, 1px inner highlight, rest is fill.
	var img := Image.create(sz, sz, false, Image.FORMAT_RGBA8)
	img.fill(bg)
	# Outer border (2px)
	for i: int in sz:
		for b: int in 2:
			img.set_pixel(i, b, border)          # top
			img.set_pixel(i, sz - 1 - b, border) # bottom
			img.set_pixel(b, i, border)           # left
			img.set_pixel(sz - 1 - b, i, border) # right
	# Inner highlight line (1px, slightly brighter than bg)
	var inner := border.lerp(bg, 0.7)
	for i: int in range(2, sz - 2):
		img.set_pixel(i, 2, inner)
		img.set_pixel(i, sz - 3, inner)
		img.set_pixel(2, i, inner)
		img.set_pixel(sz - 3, i, inner)
	# Corner pixels darkened
	for cx: int in [0, 1, sz - 2, sz - 1]:
		for cy: int in [0, 1, sz - 2, sz - 1]:
			if (cx < 2 and cy < 2) or (cx >= sz - 2 and cy < 2) or (cx < 2 and cy >= sz - 2) or (cx >= sz - 2 and cy >= sz - 2):
				img.set_pixel(cx, cy, border.darkened(0.3))
	img.save_png(path)


func _make_button(path: String, face: Color, border: Color, sz: int) -> void:
	## Beveled button: highlight top-left, shadow bottom-right.
	var img := Image.create(sz, sz, false, Image.FORMAT_RGBA8)
	img.fill(face)
	var highlight := face.lightened(0.3)
	var shadow := face.darkened(0.4)
	# Border (2px)
	for i: int in sz:
		for b: int in 2:
			img.set_pixel(i, b, border)
			img.set_pixel(i, sz - 1 - b, border)
			img.set_pixel(b, i, border)
			img.set_pixel(sz - 1 - b, i, border)
	# Bevel: top highlight, bottom shadow (inside border)
	for i: int in range(2, sz - 2):
		img.set_pixel(i, 2, highlight)       # top inner highlight
		img.set_pixel(2, i, highlight)       # left inner highlight
		img.set_pixel(i, sz - 3, shadow)     # bottom inner shadow
		img.set_pixel(sz - 3, i, shadow)     # right inner shadow
	img.save_png(path)


func _make_slot(path: String, bg: Color, border: Color, sz: int) -> void:
	## Inventory slot: inset border (shadow top-left, highlight bottom-right) = recessed look.
	var img := Image.create(sz, sz, false, Image.FORMAT_RGBA8)
	img.fill(bg)
	var shadow := border.darkened(0.3)
	var highlight := border.lightened(0.2)
	# Border (2px)
	for i: int in sz:
		for b: int in 2:
			img.set_pixel(i, b, border)
			img.set_pixel(i, sz - 1 - b, border)
			img.set_pixel(b, i, border)
			img.set_pixel(sz - 1 - b, i, border)
	# Inset bevel (opposite of button — shadow on top, highlight on bottom)
	for i: int in range(2, sz - 2):
		img.set_pixel(i, 2, shadow)
		img.set_pixel(2, i, shadow)
		img.set_pixel(i, sz - 3, highlight)
		img.set_pixel(sz - 3, i, highlight)
	img.save_png(path)
  • Step 2: Verify the script is syntactically valid

Open Godot editor, open tools/extract_9patch.gd, run via Editor > Run Script. Check the output panel for "9-patch textures generated". Verify the 8 PNG files appear in assets/art/ui/.

If running headless:

# From project root — just verify files parse (actual generation requires editor)
grep -c "func _run" tools/extract_9patch.gd
# Expected: 1
  • Step 3: Configure .import settings for 9-patch textures

After generation, Godot auto-imports PNGs. But we need to ensure nearest-neighbor filtering. Create import overrides by checking that the .godot/imported/ versions use TEXTURE_FILTER_NEAREST.

In each generated PNG's .import file (auto-created by Godot), verify or set:

[params]
filter/mode=0

This happens automatically if project settings have rendering/textures/canvas_textures/default_texture_filter set to Nearest. Verify in project.godot.

  • Step 4: Commit
git add tools/extract_9patch.gd
git commit -m "feat(ui): add 9-patch texture generator script

Extracts panel, button, and slot border textures from pixel art.
Generates dark/light panels, beveled buttons (4 states), and
inset inventory slots with highlight variant."

Task 2: Create new game theme resource

Files:

  • Create: assets/theme/game_theme.tres
  • Modify: assets/theme/default_theme.tres (keep as fallback, not deleted)

This task creates the theme after 9-patch PNGs exist. If PNGs haven't been generated yet (editor script not run), use StyleBoxFlat with pixel-art-inspired settings as a working fallback that still looks drastically better than the current theme.

  • Step 1: Create game_theme.tres with StyleBoxFlat fallbacks

We build the theme in GDScript so it's reproducible and version-controllable. Create tools/generate_theme.gd:

@tool
extends EditorScript
## Generates game_theme.tres with 9-patch StyleBoxTexture entries.
## Falls back to StyleBoxFlat if 9-patch PNGs don't exist yet.

const THEME_PATH := "res://assets/theme/game_theme.tres"

## Colors (must match extract_9patch.gd palette)
const CLR_BG_DARK := Color(0.08, 0.06, 0.1)
const CLR_BG_LIGHT := Color(0.15, 0.12, 0.18)
const CLR_BORDER := Color(0.75, 0.7, 0.6)
const CLR_BORDER_DIM := Color(0.4, 0.35, 0.3)
const CLR_TEXT := Color(0.92, 0.9, 0.85)
const CLR_TEXT_DIM := Color(0.5, 0.48, 0.45)
const CLR_HIGHLIGHT := Color(1.0, 0.85, 0.4)
const CLR_BTN_FACE := Color(0.2, 0.18, 0.25)

const NINE_PATCH_MARGIN := 8  ## px — matches 24px textures with 2px border + 6px stretch zone


func _run() -> void:
	var theme := Theme.new()

	# Font
	var font := load("res://assets/art/fonts/diablo.ttf") as Font
	theme.default_font = font
	theme.default_font_size = 16

	# --- Panel ---
	theme.set_stylebox("panel", "PanelContainer", _make_panel_style())

	# --- Button ---
	theme.set_stylebox("normal", "Button", _make_button_style(CLR_BTN_FACE, CLR_BORDER_DIM))
	theme.set_stylebox("hover", "Button", _make_button_style(CLR_BTN_FACE.lightened(0.15), CLR_HIGHLIGHT))
	theme.set_stylebox("pressed", "Button", _make_button_style(CLR_BTN_FACE.darkened(0.2), CLR_BORDER_DIM))
	theme.set_stylebox("disabled", "Button", _make_button_style(CLR_BTN_FACE.darkened(0.3), Color(0.25, 0.23, 0.2, 0.5)))
	theme.set_color("font_color", "Button", CLR_TEXT)
	theme.set_color("font_hover_color", "Button", CLR_HIGHLIGHT)
	theme.set_color("font_pressed_color", "Button", CLR_TEXT.darkened(0.2))
	theme.set_color("font_disabled_color", "Button", CLR_TEXT_DIM)
	theme.set_font_size("font_size", "Button", 16)

	# --- Label ---
	theme.set_color("font_color", "Label", CLR_TEXT)
	theme.set_font_size("font_size", "Label", 16)

	# --- TooltipPanel ---
	var tooltip_panel := _make_flat_style(Color(0.1, 0.08, 0.12, 0.95), CLR_BORDER, 2)
	tooltip_panel.set_content_margin_all(8)
	theme.set_stylebox("panel", "TooltipPanel", tooltip_panel)

	# --- TooltipLabel ---
	theme.set_color("font_color", "TooltipLabel", Color(0.9, 0.85, 0.7))
	theme.set_font_size("font_size", "TooltipLabel", 14)

	# --- HSeparator / VSeparator ---
	var sep_style := StyleBoxFlat.new()
	sep_style.bg_color = CLR_BORDER_DIM
	sep_style.set_content_margin_all(0)
	# Vertical line 2px wide
	theme.set_stylebox("separator", "VSeparator", sep_style)
	theme.set_stylebox("separator", "HSeparator", sep_style)
	theme.set_constant("separation", "VSeparator", 8)
	theme.set_constant("separation", "HSeparator", 8)

	# --- ProgressBar (for HP bars) ---
	var bar_bg := _make_flat_style(Color(0.1, 0.08, 0.12), CLR_BORDER_DIM, 1)
	bar_bg.set_corner_radius_all(2)
	theme.set_stylebox("background", "ProgressBar", bar_bg)
	var bar_fill := _make_flat_style(Color(0.2, 0.8, 0.4), Color(0, 0, 0, 0), 0)
	bar_fill.set_corner_radius_all(2)
	theme.set_stylebox("fill", "ProgressBar", bar_fill)

	# Save
	ResourceSaver.save(theme, THEME_PATH)
	print("Theme saved to ", THEME_PATH)


func _try_load_9patch(path: String) -> Texture2D:
	if ResourceLoader.exists(path):
		return load(path) as Texture2D
	return null


func _make_panel_style() -> StyleBox:
	var tex := _try_load_9patch("res://assets/art/ui/9patch_panel_dark.png")
	if tex:
		var sb := StyleBoxTexture.new()
		sb.texture = tex
		sb.texture_margin_left = NINE_PATCH_MARGIN
		sb.texture_margin_top = NINE_PATCH_MARGIN
		sb.texture_margin_right = NINE_PATCH_MARGIN
		sb.texture_margin_bottom = NINE_PATCH_MARGIN
		sb.content_margin_left = 10
		sb.content_margin_top = 8
		sb.content_margin_right = 10
		sb.content_margin_bottom = 8
		return sb
	# Fallback
	var flat := _make_flat_style(CLR_BG_DARK, CLR_BORDER_DIM, 2)
	flat.set_content_margin_all(10)
	return flat


func _make_button_style(face: Color, border: Color) -> StyleBox:
	## Returns a beveled-look StyleBoxFlat (9-patch upgrade later).
	var sb := StyleBoxFlat.new()
	sb.bg_color = face
	sb.border_color = border
	sb.set_border_width_all(2)
	sb.set_corner_radius_all(2)
	sb.content_margin_left = 12
	sb.content_margin_right = 12
	sb.content_margin_top = 6
	sb.content_margin_bottom = 6
	# Bevel simulation — different top/bottom border widths
	sb.border_width_top = 2
	sb.border_width_bottom = 3
	return sb


func _make_flat_style(bg: Color, border: Color, border_w: int) -> StyleBoxFlat:
	var sb := StyleBoxFlat.new()
	sb.bg_color = bg
	sb.border_color = border
	sb.set_border_width_all(border_w)
	sb.set_corner_radius_all(2)
	return sb
  • Step 2: Run the theme generator

In Godot editor: Editor > Run Script on tools/generate_theme.gd. Verify assets/theme/game_theme.tres is created.

  • Step 3: Commit
git add tools/generate_theme.gd assets/theme/game_theme.tres
git commit -m "feat(ui): add game theme with beveled buttons and bordered panels

StyleBoxFlat-based theme with pixel-art-inspired bevels, warm color
palette, and proper tooltip styling. 9-patch texture upgrade path
built in."

Task 3: Create reusable UI components — HeartBar

Files:

  • Create: src/ui/components/heart_bar.gd

A horizontal row of heart sprites showing current/max lives. Replaces text like "3/3".

  • Step 1: Write heart_bar.gd
class_name HeartBar
extends HBoxContainer
## Displays lives as full/empty heart sprites.

const HEART_FULL_PATH := "res://assets/art/ui/heart_full.png"
const HEART_EMPTY_PATH := "res://assets/art/ui/heart_empty.png"
const HEART_SIZE := Vector2(20, 20)

var _hearts: Array[TextureRect] = []
var _max_lives: int = 0


func setup(max_lives: int, current_lives: int) -> void:
	_max_lives = max_lives
	_rebuild(current_lives)


func update_lives(current_lives: int) -> void:
	if _hearts.size() != _max_lives:
		_rebuild(current_lives)
		return
	for i: int in _hearts.size():
		var tex_path := HEART_FULL_PATH if i < current_lives else HEART_EMPTY_PATH
		_hearts[i].texture = load(tex_path)


func _rebuild(current_lives: int) -> void:
	for child: Node in get_children():
		child.queue_free()
	_hearts.clear()
	add_theme_constant_override("separation", 2)
	for i: int in _max_lives:
		var heart := TextureRect.new()
		heart.custom_minimum_size = HEART_SIZE
		heart.expand_mode = TextureRect.EXPAND_FIT_WIDTH_PROPORTIONAL
		heart.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
		heart.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST
		var tex_path := HEART_FULL_PATH if i < current_lives else HEART_EMPTY_PATH
		heart.texture = load(tex_path)
		heart.mouse_filter = Control.MOUSE_FILTER_IGNORE
		add_child(heart)
		_hearts.append(heart)
  • Step 2: Verify it parses
grep -c "class_name HeartBar" src/ui/components/heart_bar.gd
# Expected: 1
  • Step 3: Commit
git add src/ui/components/heart_bar.gd
git commit -m "feat(ui): add HeartBar component — lives as heart sprites"

Task 4: Create reusable UI components — StatPips

Files:

  • Create: src/ui/components/stat_pips.gd

Filled/empty circles for rerolls, mana, or any countable resource. Replaces text like "Rerolls: 2".

  • Step 1: Write stat_pips.gd
class_name StatPips
extends HBoxContainer
## Displays a resource as filled/empty pip dots.

@export var pip_color: Color = Color(0.9, 0.8, 0.5)
@export var empty_color: Color = Color(0.25, 0.22, 0.2)
@export var pip_size: float = 10.0

var _max_val: int = 0
var _pips: Array[ColorRect] = []


func setup(max_val: int, current: int) -> void:
	_max_val = max_val
	_rebuild(current)


func update_value(current: int) -> void:
	for i: int in _pips.size():
		_pips[i].color = pip_color if i < current else empty_color


func _rebuild(current: int) -> void:
	for child: Node in get_children():
		child.queue_free()
	_pips.clear()
	add_theme_constant_override("separation", 4)
	for i: int in _max_val:
		var pip := ColorRect.new()
		pip.custom_minimum_size = Vector2(pip_size, pip_size)
		pip.color = pip_color if i < current else empty_color
		pip.mouse_filter = Control.MOUSE_FILTER_IGNORE
		add_child(pip)
		_pips.append(pip)
  • Step 2: Commit
git add src/ui/components/stat_pips.gd
git commit -m "feat(ui): add StatPips component — resource dots instead of text"

Task 5: Create reusable UI components — HPBar

Files:

  • Create: src/ui/components/hp_bar.gd

A color-coded progress bar for enemy HP. Replaces the text "45/80" with a visual bar + small text overlay.

  • Step 1: Write hp_bar.gd
class_name HPBar
extends Control
## Color-coded HP bar with text overlay. Green > 60%, yellow > 30%, red below.

const CLR_GREEN := Color(0.2, 0.8, 0.4)
const CLR_YELLOW := Color(0.9, 0.7, 0.2)
const CLR_RED := Color(0.9, 0.2, 0.2)
const CLR_BG := Color(0.1, 0.08, 0.12)
const CLR_BORDER := Color(0.35, 0.3, 0.25)

var _bar_bg: ColorRect
var _bar_fill: ColorRect
var _label: Label
var _max_hp: int = 1


func _init() -> void:
	custom_minimum_size = Vector2(120, 14)
	mouse_filter = Control.MOUSE_FILTER_IGNORE

	_bar_bg = ColorRect.new()
	_bar_bg.color = CLR_BG
	_bar_bg.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_bar_bg)

	_bar_fill = ColorRect.new()
	_bar_fill.color = CLR_GREEN
	_bar_fill.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_bar_fill)

	_label = Label.new()
	_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
	_label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
	_label.add_theme_font_size_override("font_size", 11)
	_label.mouse_filter = Control.MOUSE_FILTER_IGNORE
	add_child(_label)


func setup(max_hp: int, current_hp: int) -> void:
	_max_hp = max_hp
	update_hp(current_hp)


func update_hp(current_hp: int) -> void:
	var pct := float(current_hp) / float(_max_hp) if _max_hp > 0 else 0.0
	if pct > 0.6:
		_bar_fill.color = CLR_GREEN
	elif pct > 0.3:
		_bar_fill.color = CLR_YELLOW
	else:
		_bar_fill.color = CLR_RED
	_label.text = "%d/%d" % [current_hp, _max_hp]
	# Layout is handled in _process or resized signal
	_update_layout()


func _update_layout() -> void:
	var sz := size
	_bar_bg.position = Vector2.ZERO
	_bar_bg.size = sz
	var pct := 0.0
	if _max_hp > 0 and _label.text != "":
		var parts := _label.text.split("/")
		if parts.size() == 2:
			pct = float(parts[0]) / float(_max_hp)
	_bar_fill.position = Vector2.ZERO
	_bar_fill.size = Vector2(sz.x * pct, sz.y)
	_label.position = Vector2.ZERO
	_label.size = sz


func _notification(what: int) -> void:
	if what == NOTIFICATION_RESIZED:
		_update_layout()
  • Step 2: Commit
git add src/ui/components/hp_bar.gd
git commit -m "feat(ui): add HPBar component — color-coded enemy health bar"

Task 6: Create reusable UI components — InventorySlot

Files:

  • Create: src/ui/components/inventory_slot.gd
  • Create: src/ui/components/inventory_slot.tscn

A framed icon slot for abilities, items, or relics. Shows icon, optional label, border highlight on hover, click-to-activate. This replaces the manually-built PanelContainer+Button overlay pattern currently used in _rebuild_innate_buttons() and _rebuild_consumable_buttons().

  • Step 1: Write inventory_slot.tscn
[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://src/ui/components/inventory_slot.gd" id="1"]

[node name="InventorySlot" type="PanelContainer"]
custom_minimum_size = Vector2(48, 56)
script = ExtResource("1")

[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 1

[node name="IconCenter" type="CenterContainer" parent="VBox"]
layout_mode = 2
size_flags_vertical = 3

[node name="Icon" type="TextureRect" parent="VBox/IconCenter"]
custom_minimum_size = Vector2(28, 28)
layout_mode = 2
expand_mode = 1
stretch_mode = 5
texture_filter = 0

[node name="NameLabel" type="Label" parent="VBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 10
horizontal_alignment = 1

[node name="ClickButton" type="Button" parent="."]
layout_mode = 2
flat = true
mouse_filter = 0
  • Step 2: Write inventory_slot.gd
class_name InventorySlot
extends PanelContainer
## Reusable framed inventory slot with icon, label, tooltip, and click handler.

signal slot_clicked(slot_index: int)

@onready var _icon: TextureRect = %Icon if has_node("%Icon") else $VBox/IconCenter/Icon
@onready var _name_label: Label = $VBox/NameLabel
@onready var _click_button: Button = $ClickButton

var slot_index: int = -1
var _is_empty: bool = true
var _style_normal: StyleBoxFlat
var _style_hover: StyleBoxFlat
var _style_disabled: StyleBoxFlat

const CLR_SLOT_BG := Color(0.05, 0.04, 0.07)
const CLR_SLOT_BORDER := Color(0.4, 0.35, 0.3)
const CLR_SLOT_HOVER := Color(1.0, 0.85, 0.4)
const CLR_SLOT_EMPTY := Color(0.15, 0.12, 0.15, 0.4)
const CLR_SLOT_DISABLED := Color(0.08, 0.06, 0.08)


func _ready() -> void:
	_style_normal = _make_slot_style(CLR_SLOT_BG, CLR_SLOT_BORDER)
	_style_hover = _make_slot_style(CLR_SLOT_BG, CLR_SLOT_HOVER)
	_style_disabled = _make_slot_style(CLR_SLOT_DISABLED, Color(0.2, 0.18, 0.15, 0.5))
	add_theme_stylebox_override("panel", _style_normal)

	_click_button.pressed.connect(_on_clicked)
	_click_button.mouse_entered.connect(_on_hover_enter)
	_click_button.mouse_exited.connect(_on_hover_exit)

	# Start empty
	show_empty()


func setup_item(icon_texture: Texture2D, item_name: String, tooltip: String, index: int, enabled: bool = true) -> void:
	_is_empty = false
	slot_index = index
	_icon.texture = icon_texture
	_icon.modulate = Color.WHITE if enabled else Color(0.4, 0.4, 0.4)
	_name_label.text = item_name
	_click_button.tooltip_text = tooltip
	_click_button.disabled = not enabled
	modulate = Color.WHITE if enabled else Color(0.5, 0.5, 0.5)
	add_theme_stylebox_override("panel", _style_normal if enabled else _style_disabled)


func show_empty() -> void:
	_is_empty = true
	_icon.texture = null
	_name_label.text = ""
	_click_button.tooltip_text = "Empty slot"
	_click_button.disabled = true
	var empty_style := _make_slot_style(CLR_SLOT_EMPTY, Color(0.2, 0.18, 0.15, 0.3))
	add_theme_stylebox_override("panel", empty_style)
	modulate = Color(0.5, 0.5, 0.5, 0.5)


func _on_clicked() -> void:
	if not _is_empty:
		slot_clicked.emit(slot_index)


func _on_hover_enter() -> void:
	if not _is_empty and not _click_button.disabled:
		add_theme_stylebox_override("panel", _style_hover)


func _on_hover_exit() -> void:
	if not _is_empty and not _click_button.disabled:
		add_theme_stylebox_override("panel", _style_normal)


func _make_slot_style(bg: Color, border: Color) -> StyleBoxFlat:
	var sb := StyleBoxFlat.new()
	sb.bg_color = bg
	sb.border_color = border
	sb.set_border_width_all(2)
	sb.set_corner_radius_all(2)
	sb.content_margin_left = 4
	sb.content_margin_right = 4
	sb.content_margin_top = 3
	sb.content_margin_bottom = 3
	return sb
  • Step 3: Commit
git add src/ui/components/inventory_slot.gd src/ui/components/inventory_slot.tscn
git commit -m "feat(ui): add InventorySlot component — framed icon slot with hover/click"

Task 7: Create BottomPanel component — 3 framed sections

Files:

  • Create: src/ui/components/bottom_panel.gd
  • Create: src/ui/components/bottom_panel.tscn

The bottom panel replaces the current single-HBox bottom bar. Three distinct bordered sections: Abilities (left, fixed small), Items (center, medium), Relics (right, wrapping grid). Each section has a header label and its own 9-patch border.

  • Step 1: Write bottom_panel.tscn
[gd_scene load_steps=2 format=3]

[ext_resource type="Script" path="res://src/ui/components/bottom_panel.gd" id="1"]

[node name="BottomPanel" type="PanelContainer"]
custom_minimum_size = Vector2(0, 90)
script = ExtResource("1")

[node name="HBox" type="HBoxContainer" parent="."]
layout_mode = 2
theme_override_constants/separation = 6

[node name="AbilitySection" type="PanelContainer" parent="HBox"]
layout_mode = 2
custom_minimum_size = Vector2(160, 0)

[node name="AbilityVBox" type="VBoxContainer" parent="HBox/AbilitySection"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="AbilityHeader" type="HBoxContainer" parent="HBox/AbilitySection/AbilityVBox"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="ManaIcon" type="TextureRect" parent="HBox/AbilitySection/AbilityVBox/AbilityHeader"]
custom_minimum_size = Vector2(14, 14)
layout_mode = 2
texture_filter = 0
expand_mode = 1
stretch_mode = 5

[node name="HeaderLabel" type="Label" parent="HBox/AbilitySection/AbilityVBox/AbilityHeader"]
layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(0.6, 0.6, 0.9, 1)
text = "ABILITIES"

[node name="ManaPips" type="HBoxContainer" parent="HBox/AbilitySection/AbilityVBox/AbilityHeader"]
layout_mode = 2

[node name="SlotRow" type="HBoxContainer" parent="HBox/AbilitySection/AbilityVBox"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="ItemSection" type="PanelContainer" parent="HBox"]
layout_mode = 2
size_flags_horizontal = 3

[node name="ItemVBox" type="VBoxContainer" parent="HBox/ItemSection"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="ItemHeader" type="HBoxContainer" parent="HBox/ItemSection/ItemVBox"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="HeaderLabel" type="Label" parent="HBox/ItemSection/ItemVBox/ItemHeader"]
layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(0.6, 0.9, 0.6, 1)
text = "ITEMS"

[node name="CountLabel" type="Label" parent="HBox/ItemSection/ItemVBox/ItemHeader"]
layout_mode = 2
theme_override_font_sizes/font_size = 11
theme_override_colors/font_color = Color(0.5, 0.7, 0.5, 1)

[node name="SlotRow" type="HBoxContainer" parent="HBox/ItemSection/ItemVBox"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="RelicSection" type="PanelContainer" parent="HBox"]
layout_mode = 2
size_flags_horizontal = 3

[node name="RelicVBox" type="VBoxContainer" parent="HBox/RelicSection"]
layout_mode = 2
theme_override_constants/separation = 4

[node name="RelicHeader" type="Label" parent="HBox/RelicSection/RelicVBox"]
layout_mode = 2
theme_override_font_sizes/font_size = 12
theme_override_colors/font_color = Color(1, 0.8, 0.5, 1)
text = "RELICS"

[node name="RelicGrid" type="GridContainer" parent="HBox/RelicSection/RelicVBox"]
layout_mode = 2
columns = 8
theme_override_constants/h_separation = 3
theme_override_constants/v_separation = 3
  • Step 2: Write bottom_panel.gd
class_name BottomPanel
extends PanelContainer
## Three-section bottom bar: Abilities | Items | Relics.
## Abilities and Items use InventorySlot instances.
## Relics use a GridContainer of small icons (wraps to multiple rows).

signal ability_clicked(index: int)
signal consumable_clicked(index: int)

const InventorySlotScene := preload("res://src/ui/components/inventory_slot.tscn")

@onready var _ability_slot_row: HBoxContainer = $HBox/AbilitySection/AbilityVBox/SlotRow
@onready var _mana_pips_container: HBoxContainer = $HBox/AbilitySection/AbilityVBox/AbilityHeader/ManaPips
@onready var _mana_icon: TextureRect = $HBox/AbilitySection/AbilityVBox/AbilityHeader/ManaIcon
@onready var _item_slot_row: HBoxContainer = $HBox/ItemSection/ItemVBox/SlotRow
@onready var _item_count_label: Label = $HBox/ItemSection/ItemVBox/ItemHeader/CountLabel
@onready var _relic_grid: GridContainer = $HBox/RelicSection/RelicVBox/RelicGrid
@onready var _ability_section: PanelContainer = $HBox/AbilitySection
@onready var _item_section: PanelContainer = $HBox/ItemSection
@onready var _relic_section: PanelContainer = $HBox/RelicSection

var _mana_pips: StatPips
var _ability_slots: Array[InventorySlot] = []
var _item_slots: Array[InventorySlot] = []

const SECTION_STYLE_BG := Color(0.06, 0.05, 0.08)
const SECTION_STYLE_BORDER := Color(0.3, 0.27, 0.22)


func _ready() -> void:
	# Style the three sections with bordered panels
	_style_section(_ability_section)
	_style_section(_item_section)
	_style_section(_relic_section)

	# Style self (outer container)
	var outer := StyleBoxFlat.new()
	outer.bg_color = Color(0.04, 0.03, 0.06, 0.9)
	outer.border_color = Color(0.35, 0.3, 0.25)
	outer.set_border_width_all(2)
	outer.border_width_top = 3
	outer.set_corner_radius_all(0)
	outer.content_margin_left = 8
	outer.content_margin_right = 8
	outer.content_margin_top = 6
	outer.content_margin_bottom = 6
	add_theme_stylebox_override("panel", outer)

	# Mana icon
	_mana_icon.texture = load(SpriteMap.ICON_MANA)

	# Mana pips
	_mana_pips = StatPips.new()
	_mana_pips.pip_color = Color(0.4, 0.5, 1.0)
	_mana_pips.pip_size = 8.0
	_mana_pips_container.add_child(_mana_pips)


func rebuild_abilities(abilities: Array, can_use_func: Callable) -> void:
	## abilities: Array of Dictionaries with "name", "description", "id".
	## can_use_func: Callable(index: int) -> bool.
	_clear_container(_ability_slot_row)
	_ability_slots.clear()
	for i: int in abilities.size():
		var slot: InventorySlot = InventorySlotScene.instantiate()
		_ability_slot_row.add_child(slot)
		var ab: Dictionary = abilities[i]
		var can_use: bool = can_use_func.call(i)
		var icon_tex: Texture2D = load(SpriteMap.ICON_MANA)  # Default ability icon
		slot.setup_item(icon_tex, ab.get("name", "?"), ab.get("description", ""), i, can_use)
		slot.slot_clicked.connect(func(idx: int) -> void: ability_clicked.emit(idx))
		_ability_slots.append(slot)


func update_mana(current: int, max_val: int) -> void:
	_mana_pips.setup(max_val, current)


func rebuild_consumables(consumables: Array, max_slots: int, can_use_func: Callable) -> void:
	## consumables: Array of Dictionaries with "id", "name", "description", "rarity".
	## max_slots: total slot capacity.
	## can_use_func: Callable(index: int) -> bool.
	_clear_container(_item_slot_row)
	_item_slots.clear()
	for i: int in consumables.size():
		var slot: InventorySlot = InventorySlotScene.instantiate()
		_item_slot_row.add_child(slot)
		var c: Dictionary = consumables[i]
		var can_use: bool = can_use_func.call(i)
		var cid: StringName = c.get("id", &"") as StringName
		var icon_tex: Texture2D = SpriteMap.get_consumable_texture(cid)
		slot.setup_item(icon_tex, c.get("name", "?"), c.get("description", ""), i, can_use)
		slot.slot_clicked.connect(func(idx: int) -> void: consumable_clicked.emit(idx))
		_item_slots.append(slot)
	# Empty slots
	var empty_count: int = max_slots - consumables.size()
	for i: int in empty_count:
		var slot: InventorySlot = InventorySlotScene.instantiate()
		_item_slot_row.add_child(slot)
		# show_empty() is called in _ready
	_item_count_label.text = "%d/%d" % [consumables.size(), max_slots]


func rebuild_relics(relics: Array) -> void:
	## relics: Array of Dictionaries with "id", "name", "description".
	_clear_container(_relic_grid)
	for r: Dictionary in relics:
		var rid: StringName = r.get("id", &"") as StringName
		var icon: TextureRect = SpriteMap.make_relic_icon(rid)
		icon.custom_minimum_size = Vector2(22, 22)
		icon.tooltip_text = "%s: %s" % [r.get("name", "?"), r.get("description", "")]
		_relic_grid.add_child(icon)


func _style_section(section: PanelContainer) -> void:
	var sb := StyleBoxFlat.new()
	sb.bg_color = SECTION_STYLE_BG
	sb.border_color = SECTION_STYLE_BORDER
	sb.set_border_width_all(2)
	sb.set_corner_radius_all(2)
	sb.content_margin_left = 6
	sb.content_margin_right = 6
	sb.content_margin_top = 4
	sb.content_margin_bottom = 4
	section.add_theme_stylebox_override("panel", sb)


func _clear_container(container: Node) -> void:
	for child: Node in container.get_children():
		container.remove_child(child)
		child.queue_free()
  • Step 3: Add helper to SpriteMap for consumable texture lookup

In src/core/sprite_map.gd, the existing make_consumable_icon() creates a full TextureRect. We need a texture-only getter for InventorySlot. Add this static method:

## Add to src/core/sprite_map.gd, near make_consumable_icon():

static func get_consumable_texture(consumable_id: StringName) -> Texture2D:
	var path: String = CONSUMABLE_SPRITES.get(consumable_id, "") as String
	if not path.is_empty() and ResourceLoader.exists(path):
		return load(path)
	return load(ICON_POTION)


static func get_relic_texture(relic_id: StringName) -> Texture2D:
	var path: String = RELIC_SPRITES.get(relic_id, "") as String
	if not path.is_empty() and ResourceLoader.exists(path):
		return load(path)
	return load(ICON_STAR)
  • Step 4: Commit
git add src/ui/components/bottom_panel.gd src/ui/components/bottom_panel.tscn src/core/sprite_map.gd
git commit -m "feat(ui): add BottomPanel — 3-section bar with abilities, items, relic grid

Abilities get framed inventory slots with mana pips.
Items get slots with count display and empty slot indicators.
Relics use a GridContainer that wraps to multiple rows."

Task 8: Redesign DiceCombat scene layout

Files:

  • Modify: assets/scenes/DiceCombat.tscn

Rebuild the scene tree to use the new components and layout. The scene file is large and mostly declarative, so we'll rebuild it.

  • Step 1: Rebuild DiceCombat.tscn with new layout

Replace the entire scene file with this new structure:

[gd_scene load_steps=4 format=3]

[ext_resource type="Script" uid="uid://crt6tli1il5sb" path="res://src/ui/dice_combat_screen.gd" id="1"]
[ext_resource type="Texture2D" uid="uid://naku4m4ygnaf" path="res://assets/art/dungeon/background/background_1.png" id="8"]
[ext_resource type="PackedScene" path="res://src/ui/components/bottom_panel.tscn" id="bottom_panel"]

[sub_resource type="StyleBoxFlat" id="panel_bg"]
content_margin_left = 12
content_margin_top = 8
content_margin_right = 12
content_margin_bottom = 8
bg_color = Color(0, 0, 0, 0.6)
border_color = Color(0.35, 0.3, 0.25, 0.6)
border_width_left = 0
border_width_top = 0
border_width_right = 0
border_width_bottom = 0
corner_radius_top_left = 4
corner_radius_top_right = 4
corner_radius_bottom_right = 4
corner_radius_bottom_left = 4

[sub_resource type="StyleBoxFlat" id="top_bar_bg"]
content_margin_left = 12
content_margin_top = 6
content_margin_right = 12
content_margin_bottom = 6
bg_color = Color(0.04, 0.03, 0.06, 0.85)
border_color = Color(0.35, 0.3, 0.25)
border_width_left = 2
border_width_top = 2
border_width_right = 2
border_width_bottom = 3

[node name="DiceCombat" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
script = ExtResource("1")

[node name="BG" type="TextureRect" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
texture = ExtResource("8")
expand_mode = 1
stretch_mode = 6

[node name="Layout" type="MarginContainer" parent="."]
layout_mode = 1
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
theme_override_constants/margin_left = 16
theme_override_constants/margin_top = 8
theme_override_constants/margin_right = 16
theme_override_constants/margin_bottom = 8

[node name="VBox" type="VBoxContainer" parent="Layout"]
layout_mode = 2
theme_override_constants/separation = 6

[node name="TopBar" type="PanelContainer" parent="Layout/VBox"]
layout_mode = 2
theme_override_styles/panel = SubResource("top_bar_bg")

[node name="TopBarHBox" type="HBoxContainer" parent="Layout/VBox/TopBar"]
layout_mode = 2
theme_override_constants/separation = 12

[node name="HeartBar" type="HBoxContainer" parent="Layout/VBox/TopBar/TopBarHBox" unique_id=1]
unique_name_in_owner = true
layout_mode = 2

[node name="Sep1" type="VSeparator" parent="Layout/VBox/TopBar/TopBarHBox"]
layout_mode = 2

[node name="PhaseLabel" type="Label" parent="Layout/VBox/TopBar/TopBarHBox" unique_id=2]
unique_name_in_owner = true
layout_mode = 2
size_flags_horizontal = 3
horizontal_alignment = 1
text = "Turn 1"

[node name="Sep2" type="VSeparator" parent="Layout/VBox/TopBar/TopBarHBox"]
layout_mode = 2

[node name="TurnsLabel" type="Label" parent="Layout/VBox/TopBar/TopBarHBox" unique_id=3]
unique_name_in_owner = true
modulate = Color(0.9, 0.7, 0.4, 1)
layout_mode = 2

[node name="StatusLabel" type="Label" parent="Layout/VBox/TopBar/TopBarHBox" unique_id=4]
unique_name_in_owner = true
modulate = Color(1, 0.8, 0.5, 1)
layout_mode = 2

[node name="PendingWarningLabel" type="Label" parent="Layout/VBox/TopBar/TopBarHBox" unique_id=5]
unique_name_in_owner = true
modulate = Color(1, 0.5, 0.2, 1)
layout_mode = 2

[node name="EnemyArea" type="CenterContainer" parent="Layout/VBox"]
layout_mode = 2
size_flags_vertical = 3

[node name="EnemyContainer" type="HBoxContainer" parent="Layout/VBox/EnemyArea" unique_id=6]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 16

[node name="ScoringArea" type="VBoxContainer" parent="Layout/VBox"]
layout_mode = 2
theme_override_constants/separation = 2

[node name="HandLabel" type="Label" parent="Layout/VBox/ScoringArea" unique_id=7]
unique_name_in_owner = true
layout_mode = 2
theme_override_font_sizes/font_size = 22
horizontal_alignment = 1

[node name="ScoreLabel" type="Label" parent="Layout/VBox/ScoringArea" unique_id=8]
unique_name_in_owner = true
modulate = Color(1, 1, 0.5, 1)
layout_mode = 2
theme_override_font_sizes/font_size = 28
horizontal_alignment = 1

[node name="TargetingLabel" type="Label" parent="Layout/VBox" unique_id=9]
unique_name_in_owner = true
layout_mode = 2
theme_override_colors/font_color = Color(1, 1, 0.5, 1)
theme_override_font_sizes/font_size = 14
horizontal_alignment = 1

[node name="DiceArea" type="CenterContainer" parent="Layout/VBox"]
layout_mode = 2
size_flags_vertical = 3

[node name="DiceContainer" type="HBoxContainer" parent="Layout/VBox/DiceArea" unique_id=10]
unique_name_in_owner = true
layout_mode = 2
theme_override_constants/separation = 12

[node name="ActionBar" type="HBoxContainer" parent="Layout/VBox"]
layout_mode = 2
theme_override_constants/separation = 16
alignment = 1

[node name="RerollButton" type="Button" parent="Layout/VBox/ActionBar" unique_id=11]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 44)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "Roll"

[node name="RerollPips" type="HBoxContainer" parent="Layout/VBox/ActionBar" unique_id=12]
unique_name_in_owner = true
layout_mode = 2

[node name="ScoreButton" type="Button" parent="Layout/VBox/ActionBar" unique_id=13]
unique_name_in_owner = true
custom_minimum_size = Vector2(160, 44)
layout_mode = 2
theme_override_font_sizes/font_size = 16
text = "Score"

[node name="BottomPanel" parent="Layout/VBox" instance=ExtResource("bottom_panel") unique_id=14]
unique_name_in_owner = true
layout_mode = 2

Note: The unique_name_in_owner on key nodes allows %NodeName references. UIDs may need updating after first open in editor — Godot will assign them automatically.

  • Step 2: Commit the scene
git add assets/scenes/DiceCombat.tscn
git commit -m "feat(ui): redesign DiceCombat scene layout

New structure: bordered top bar with hearts, centered scoring area,
action bar with reroll pips, and BottomPanel component for
abilities/items/relics."

Task 9: Refactor dice_combat_screen.gd to use new components

Files:

  • Modify: src/ui/dice_combat_screen.gd

This is the largest task — refactoring the 950+ line combat screen to use new components. The approach: update @onready references, replace manual panel building with component API calls, wire up signals.

Key changes:

  • Remove relic_label, innate_container, consumable_container, hp_label, block_label, rerolls_label references

  • Add heart_bar: HeartBar, reroll_pips: StatPips, bottom_panel: BottomPanel references

  • Replace _rebuild_innate_buttons()bottom_panel.rebuild_abilities()

  • Replace _rebuild_consumable_buttons()bottom_panel.rebuild_consumables()

  • Replace relic icon loop in _update_player_state()bottom_panel.rebuild_relics()

  • Replace HP text label → heart_bar.update_lives()

  • Replace rerolls text → reroll_pips.update_value()

  • Use HPBar in _update_enemy_display() (uncomment and use component)

  • Step 1: Update @onready declarations (top of file)

Replace the old node references block (lines 1-31) with updated references:

extends Control
## Dice combat UI: dice display, hold/reroll, score, enemy display.

@onready var dice_container: HBoxContainer = %DiceContainer
@onready var enemy_container: HBoxContainer = %EnemyContainer
@onready var reroll_button: Button = %RerollButton
@onready var score_button: Button = %ScoreButton
@onready var hand_label: Label = %HandLabel
@onready var score_label: Label = %ScoreLabel
@onready var turns_label: Label = %TurnsLabel
@onready var phase_label: Label = %PhaseLabel
@onready var status_label: Label = %StatusLabel
@onready var targeting_label: Label = %TargetingLabel
@onready var pending_warning_label: Label = %PendingWarningLabel
@onready var bottom_panel: BottomPanel = %BottomPanel
@onready var heart_bar_container: HBoxContainer = %HeartBar
@onready var reroll_pips_container: HBoxContainer = %RerollPips
@onready var vbox: Control = $Layout/VBox  ## For screen shake offset

var combat: DiceCombatManager
var dice_buttons: Array[Button] = []
var _value_picker_popup: BoxContainer = null
var _rolling_anim: bool = false
var _pending_death_enemies: Array[int] = []
var _in_scoring_phase: bool = false
var _roll_tween: Tween = null

## New components (created in _ready)
var _heart_bar: HeartBar
var _reroll_pips: StatPips

## Particle containers for effects
var _hit_particles: GPUParticles2D
var _score_particles: GPUParticles2D
var _death_particles: GPUParticles2D
var _die_lock_particles: GPUParticles2D
  • Step 2: Update _ready() to initialize components and wire bottom panel signals

Replace the _ready() function:

func _ready() -> void:
	reroll_button.pressed.connect(_on_reroll)
	score_button.pressed.connect(_on_score)
	targeting_label.text = ""

	# Initialize heart bar component
	_heart_bar = HeartBar.new()
	heart_bar_container.add_child(_heart_bar)

	# Initialize reroll pips
	_reroll_pips = StatPips.new()
	_reroll_pips.pip_color = Color(0.9, 0.8, 0.5)
	_reroll_pips.pip_size = 10.0
	reroll_pips_container.add_child(_reroll_pips)

	# Wire bottom panel signals
	bottom_panel.ability_clicked.connect(_on_innate_clicked)
	bottom_panel.consumable_clicked.connect(_on_consumable_clicked)

	# Setup particle effects
	_setup_particles()

	# Create a local dice combat for solo play
	_start_local_combat()
  • Step 3: Replace _update_player_state()

Replace the _update_player_state() function (around line 930):

func _update_player_state() -> void:
	var cs: Dictionary = combat.get_combat_state()
	var lives: int = 0
	var max_lives: int = 3
	if SoloGameManager.is_active and not SoloGameManager.run_data.players.is_empty():
		lives = SoloGameManager.run_data.players[0].lives
		max_lives = SoloGameManager.run_data.players[0].max_lives

	# Hearts instead of text
	_heart_bar.setup(max_lives, lives)

	# Turns
	var turns_left: int = cs.get("turns_remaining", 0) as int + 1
	turns_label.text = "%d turns" % turns_left if turns_left > 0 else "!"
	turns_label.modulate = Color(1, 0.3, 0.3) if turns_left <= 1 else Color(0.9, 0.7, 0.4)

	# Streak
	var streak: int = cs.get("streak_count", 0) as int
	status_label.text = "Streak: %d" % streak if streak >= 2 else ""

	# Reroll pips
	var rerolls: int = cs.get("rerolls_remaining", 0) as int
	var max_rerolls: int = cs.get("max_rerolls", 2) as int
	_reroll_pips.setup(max_rerolls, rerolls)

	# Bottom panel: relics
	bottom_panel.rebuild_relics(combat.relics)

	# Bottom panel: mana
	var mana: int = cs.get("mana", 0) as int
	var max_mana: int = cs.get("max_mana", 3) as int
	bottom_panel.update_mana(mana, max_mana)
  • Step 4: Replace _rebuild_innate_buttons() and _rebuild_consumable_buttons()

Delete the old _rebuild_innate_buttons() (lines ~1275-1317) and _rebuild_consumable_buttons() (lines ~1320-1393). Replace with:

func _rebuild_innate_buttons() -> void:
	bottom_panel.rebuild_abilities(
		combat.manipulation.innate_abilities,
		combat.manipulation.can_use_innate
	)


func _rebuild_consumable_buttons() -> void:
	bottom_panel.rebuild_consumables(
		combat.manipulation.consumables,
		combat.manipulation.max_consumable_slots,
		combat.manipulation.can_use_consumable
	)

Also remove the old innate_buttons and consumable_buttons arrays since the BottomPanel manages them now.

  • Step 5: Update _update_enemy_display() to use HPBar

In _update_enemy_display() (around line 970), replace the text HP display with the HPBar component. Find the HP row section (lines ~1066-1083) and replace it:

		# HP bar (replaces text HP display)
		var hp_bar := HPBar.new()
		hp_bar.custom_minimum_size = Vector2(130, 14)
		hp_bar.setup(enemy.max_hp, enemy.current_hp)
		vbox.add_child(hp_bar)

		# Block indicator (shown next to HP bar if any)
		if enemy.block > 0:
			var block_row: HBoxContainer = HBoxContainer.new()
			block_row.alignment = BoxContainer.ALIGNMENT_CENTER
			block_row.add_theme_constant_override("separation", 4)
			block_row.mouse_filter = Control.MOUSE_FILTER_IGNORE
			block_row.add_child(SpriteMap.make_icon(SpriteMap.ICON_BLOCK, Vector2(14, 14)))
			var blk_lbl: Label = Label.new()
			blk_lbl.text = str(enemy.block)
			blk_lbl.modulate = Color(0.4, 0.7, 1)
			blk_lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
			block_row.add_child(blk_lbl)
			vbox.add_child(block_row)
  • Step 6: Update _on_roll_phase to use new references

In _on_roll_phase() (around line 254), update the reroll button text and remove old label references:

func _on_roll_phase(turn_num: int, turns_left: int) -> void:
	phase_label.text = "Turn %d" % turn_num
	targeting_label.text = ""
	reroll_button.text = "Roll"
	reroll_button.disabled = false
	score_button.disabled = true
	hand_label.text = ""
	score_label.text = ""
	_rebuild_dice_display(combat.dice_pool.results)
	_update_player_state()
	_update_enemy_display()

	_rebuild_innate_buttons()
	_rebuild_consumable_buttons()
	_update_pending_warning()
	if turns_left <= 1:
		_urgency_pulse(phase_label)
  • Step 7: Remove old references and variables that no longer exist

Remove these from the @onready section and any code that references them:

  • hp_label — replaced by _heart_bar
  • block_label — removed from top bar (shown on enemy panels only)
  • rerolls_label — replaced by _reroll_pips
  • relic_label — replaced by bottom_panel.rebuild_relics()
  • innate_container — replaced by bottom_panel
  • consumable_container — replaced by bottom_panel
  • innate_buttons array — managed by BottomPanel
  • consumable_buttons array — managed by BottomPanel

Search the file for any remaining references to these variables and update them. Key places to check:

  • _on_dice_rerolled may reference rerolls_label — update to use _reroll_pips

  • _on_hand_scored may reference old labels

  • _consumable_rarity_color() — can be removed (InventorySlot handles this)

  • Step 8: Commit

git add src/ui/dice_combat_screen.gd
git commit -m "refactor(ui): combat screen uses new components

HeartBar for lives, StatPips for rerolls/mana, HPBar for enemies,
BottomPanel for abilities/items/relics. Removed ~200 lines of
manual panel building code."

Task 10: Apply new theme to all screens

Files:

  • Modify: assets/scenes/Main.tscn
  • Modify: assets/scenes/Shop.tscn
  • Modify: assets/scenes/DiceReward.tscn
  • Modify: assets/scenes/SoloMap.tscn

Apply the new game_theme.tres to each scene's root node so all screens use consistent styling.

  • Step 1: Add theme to each scene root node

In each .tscn file, add to the root Control node:

theme = ExtResource("game_theme_id")

And add the ext_resource at the top:

[ext_resource type="Theme" path="res://assets/theme/game_theme.tres" id="game_theme_id"]

Do this for Main.tscn, Shop.tscn, DiceReward.tscn, SoloMap.tscn, and DiceCombat.tscn.

  • Step 2: Update SoloMapScreen to use HeartBar

In src/ui/solo_map_screen.gd, find where lives are displayed as heart icons manually (the loop creating heart TextureRects in the info bar). Replace with:

# In the info bar setup, replace manual heart creation with:
var heart_bar := HeartBar.new()
heart_bar.setup(max_lives, lives)
info_bar.add_child(heart_bar)
  • Step 3: Commit
git add assets/scenes/Main.tscn assets/scenes/Shop.tscn assets/scenes/DiceReward.tscn assets/scenes/SoloMap.tscn src/ui/solo_map_screen.gd
git commit -m "feat(ui): apply game theme to all screens, HeartBar on map

Consistent bordered panels, beveled buttons, and warm color palette
across menu, shop, rewards, and map screens."

Task 11: Integration test — run the game and verify

Files: (no new files)

  • Step 1: Run the game from Godot editor

Launch the game, start a run, and verify:

  1. Main menu: buttons have beveled borders, not flat rectangles
  2. Map screen: heart sprites show lives, theme looks consistent
  3. Combat screen: top bar has bordered panel, hearts show lives
  4. Combat screen: dice area and scoring area are centered and readable
  5. Combat screen: bottom panel has 3 distinct bordered sections
  6. Combat screen: abilities show as framed inventory slots
  7. Combat screen: items show as framed inventory slots with empty slot indicators
  8. Combat screen: relics show in a wrapping grid (test with 10+ relics)
  9. Combat screen: rerolls show as filled/empty dots
  10. Combat screen: enemy HP shows as a color-coded bar
  11. Shop screen: items have consistent panel styling
  12. Reward screen: options have consistent panel styling
  • Step 2: Fix any runtime errors

Common issues to watch for:

  • %NodeName not found — check unique_name_in_owner in scene

  • Null references from removed @onready vars — search for old names

  • Signal connection errors — verify BottomPanel signal names match

  • Layout overflow — adjust custom_minimum_size values

  • Step 3: Screenshot before/after for comparison

Take screenshots of the combat screen before and after to verify the improvement.

  • Step 4: Final commit
git add -A
git commit -m "fix(ui): integration fixes from playtesting new UI layout"

Summary

TaskWhatEst. Complexity
1Extract 9-patch texturesLow — editor script
2Create game themeMedium — StyleBox config
3HeartBar componentLow — simple sprite row
4StatPips componentLow — colored dots
5HPBar componentLow — colored bar + text
6InventorySlot componentMedium — framed slot with hover/click
7BottomPanel componentMedium — 3-section layout + signals
8DiceCombat scene rebuildMedium — new scene tree
9Combat screen refactorHigh — largest code change
10Apply theme to all screensLow — theme + minor updates
11Integration testingMedium — full playthrough
Built with LogoFlowershow