UI Redesign — Layout + Theme Implementation Plan
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
| Action | File | Responsibility |
|---|---|---|
| Create | assets/theme/game_theme.tres | New master theme with 9-patch StyleBoxTexture for all Control types |
| Create | assets/art/ui/9patch_panel_dark.png | Extracted 9-patch panel border (dark variant) |
| Create | assets/art/ui/9patch_panel_light.png | Extracted 9-patch panel border (light variant) |
| Create | assets/art/ui/9patch_button_normal.png | Extracted 9-patch button (normal state) |
| Create | assets/art/ui/9patch_button_hover.png | Extracted 9-patch button (hover state) |
| Create | assets/art/ui/9patch_button_pressed.png | Extracted 9-patch button (pressed state) |
| Create | assets/art/ui/9patch_button_disabled.png | Extracted 9-patch button (disabled state) |
| Create | assets/art/ui/9patch_slot.png | Inventory slot frame (for abilities/items) |
| Create | assets/art/ui/9patch_slot_highlight.png | Inventory slot frame highlighted (hover/active) |
| Create | tools/extract_9patch.gd | Editor script to extract 9-patch textures from atlas |
| Create | src/ui/components/inventory_slot.gd | Reusable inventory slot component (icon + frame + tooltip + click) |
| Create | src/ui/components/inventory_slot.tscn | Scene for inventory slot |
| Create | src/ui/components/stat_pips.gd | Reusable pip indicator (filled/empty dots for rerolls, mana) |
| Create | src/ui/components/heart_bar.gd | Reusable heart sprite bar (full/empty hearts for lives) |
| Create | src/ui/components/hp_bar.gd | Reusable HP bar with color thresholds (for enemies) |
| Create | src/ui/components/bottom_panel.gd | Bottom bar with 3 framed sections: abilities, items, relics |
| Create | src/ui/components/bottom_panel.tscn | Scene for bottom panel |
| Modify | assets/scenes/DiceCombat.tscn | New layout structure using new components |
| Modify | src/ui/dice_combat_screen.gd | Refactored to use new components, remove manual .new() panel building |
| Modify | src/core/sprite_map.gd | Add SLOT_SIZE constant, make_slot_icon() factory |
| Modify | assets/scenes/Shop.tscn | Apply new theme |
| Modify | src/ui/shop_screen.gd | Use inventory_slot for shop items, apply new theme |
| Modify | assets/scenes/DiceReward.tscn | Apply new theme |
| Modify | src/ui/dice_reward_screen.gd | Use inventory_slot for reward items |
| Modify | assets/scenes/SoloMap.tscn | Apply new theme, heart_bar for lives |
| Modify | src/ui/solo_map_screen.gd | Use heart_bar, stat_pips |
| Modify | assets/scenes/Main.tscn | Apply 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_labelreferences -
Add
heart_bar: HeartBar,reroll_pips: StatPips,bottom_panel: BottomPanelreferences -
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_barblock_label— removed from top bar (shown on enemy panels only)rerolls_label— replaced by_reroll_pipsrelic_label— replaced bybottom_panel.rebuild_relics()innate_container— replaced bybottom_panelconsumable_container— replaced bybottom_panelinnate_buttonsarray — managed by BottomPanelconsumable_buttonsarray — managed by BottomPanel
Search the file for any remaining references to these variables and update them. Key places to check:
-
_on_dice_rerolledmay referencererolls_label— update to use_reroll_pips -
_on_hand_scoredmay 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:
- Main menu: buttons have beveled borders, not flat rectangles
- Map screen: heart sprites show lives, theme looks consistent
- Combat screen: top bar has bordered panel, hearts show lives
- Combat screen: dice area and scoring area are centered and readable
- Combat screen: bottom panel has 3 distinct bordered sections
- Combat screen: abilities show as framed inventory slots
- Combat screen: items show as framed inventory slots with empty slot indicators
- Combat screen: relics show in a wrapping grid (test with 10+ relics)
- Combat screen: rerolls show as filled/empty dots
- Combat screen: enemy HP shows as a color-coded bar
- Shop screen: items have consistent panel styling
- Reward screen: options have consistent panel styling
- Step 2: Fix any runtime errors
Common issues to watch for:
-
%NodeNamenot found — checkunique_name_in_ownerin scene -
Null references from removed
@onreadyvars — search for old names -
Signal connection errors — verify BottomPanel signal names match
-
Layout overflow — adjust
custom_minimum_sizevalues -
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
| Task | What | Est. Complexity |
|---|---|---|
| 1 | Extract 9-patch textures | Low — editor script |
| 2 | Create game theme | Medium — StyleBox config |
| 3 | HeartBar component | Low — simple sprite row |
| 4 | StatPips component | Low — colored dots |
| 5 | HPBar component | Low — colored bar + text |
| 6 | InventorySlot component | Medium — framed slot with hover/click |
| 7 | BottomPanel component | Medium — 3-section layout + signals |
| 8 | DiceCombat scene rebuild | Medium — new scene tree |
| 9 | Combat screen refactor | High — largest code change |
| 10 | Apply theme to all screens | Low — theme + minor updates |
| 11 | Integration testing | Medium — full playthrough |