feat(ui): implant component library + star map refactor (D-169)

New component library at client/ui/implant/:
- ImplantTheme: shared Resource with colors, spacing, font sizes
- ImplantPanel: PanelContainer root with themed background/border
- ImplantHeader: title + subtitle
- ImplantSeparator: themed horizontal rule with above/below spacing
- ImplantDataRow: single-line text with optional color override
- ImplantTextBlock: RichTextLabel for wrapping narrative text
- default_implant.tres: default theme resource

Star map info panel refactored from 140 lines of manual draw_string
calls to 50 lines of component composition via _rebuild_info_panel().

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-05 10:47:23 +02:00
co-authored by Claude Opus 4.6
parent 4dd95ba50b
commit 962596fd57
8 changed files with 313 additions and 109 deletions
+24
View File
@@ -0,0 +1,24 @@
[gd_resource type="Resource" script_class="ImplantTheme" load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/implant/implant_theme.gd" id="1"]
[resource]
script = ExtResource("1")
text_primary = Color(0.784, 0.816, 0.878, 1)
text_dim = Color(0.4, 0.467, 0.533, 1)
accent_active = Color(0.941, 0.816, 0.376, 1)
accent_positive = Color(0.267, 0.667, 0.4, 1)
accent_negative = Color(0.667, 0.267, 0.267, 1)
accent_warning = Color(0.667, 0.533, 0.267, 1)
panel_bg = Color(0.05, 0.08, 0.14, 0.92)
separator = Color(0.78, 0.82, 0.88, 0.12)
hover_bg = Color(0.78, 0.82, 0.88, 0.08)
line_height = 18
sep_above = 6
sep_below = 12
padding = 12
border_width = 1.0
font_header = 15
font_body = 11
font_small = 10
font_caption = 9
+28
View File
@@ -0,0 +1,28 @@
@tool
class_name ImplantDataRow
extends Label
## Single-line data display — text with optional color override.
var _color_override: Color = Color.TRANSPARENT # transparent = use theme default
func _init(text: String = "", color: Color = Color.TRANSPARENT) -> void:
self.text = text
_color_override = color
mouse_filter = Control.MOUSE_FILTER_IGNORE
func set_content(text_value: String, color: Color = Color.TRANSPARENT) -> void:
text = text_value
_color_override = color
if _color_override.a > 0.0:
add_theme_color_override("font_color", _color_override)
func apply_implant_theme(t: ImplantTheme) -> void:
add_theme_font_size_override("font_size", t.font_body)
if _color_override.a > 0.0:
add_theme_color_override("font_color", _color_override)
else:
add_theme_color_override("font_color", t.text_dim)
custom_minimum_size.y = t.line_height
+37
View File
@@ -0,0 +1,37 @@
@tool
class_name ImplantHeader
extends VBoxContainer
## Panel header — title + optional subtitle.
var _title_label: Label
var _subtitle_label: Label
func _init(title: String = "", subtitle: String = "") -> void:
_title_label = Label.new()
_title_label.text = title
_title_label.name = "Title"
_subtitle_label = Label.new()
_subtitle_label.text = subtitle
_subtitle_label.name = "Subtitle"
func _ready() -> void:
add_theme_constant_override("separation", 0)
add_child(_title_label)
add_child(_subtitle_label)
func set_content(title: String, subtitle: String = "") -> void:
_title_label.text = title
_subtitle_label.text = subtitle
_subtitle_label.visible = not subtitle.is_empty()
func apply_implant_theme(t: ImplantTheme) -> void:
_title_label.add_theme_font_size_override("font_size", t.font_header)
_title_label.add_theme_color_override("font_color", t.text_primary)
_subtitle_label.add_theme_font_size_override("font_size", t.font_small)
_subtitle_label.add_theme_color_override("font_color", t.text_dim)
+74
View File
@@ -0,0 +1,74 @@
@tool
class_name ImplantPanel
extends PanelContainer
## Root container for any implant UI overlay (D-169).
## Add ImplantHeader, ImplantSeparator, ImplantDataRow, ImplantTextBlock
## as children — they auto-style from the shared theme.
@export var theme_resource: ImplantTheme:
set(value):
theme_resource = value
_apply_theme()
var _vbox: VBoxContainer
func _ready() -> void:
_vbox = VBoxContainer.new()
_vbox.name = "Content"
add_child(_vbox)
_apply_theme()
func _apply_theme() -> void:
if not theme_resource:
return
# Panel background
var style := StyleBoxFlat.new()
style.bg_color = theme_resource.panel_bg
style.border_color = theme_resource.separator
style.border_width_top = int(theme_resource.border_width)
style.border_width_bottom = int(theme_resource.border_width)
style.border_width_left = int(theme_resource.border_width)
style.border_width_right = int(theme_resource.border_width)
style.content_margin_top = theme_resource.padding
style.content_margin_bottom = theme_resource.padding
style.content_margin_left = theme_resource.padding
style.content_margin_right = theme_resource.padding
add_theme_stylebox_override("panel", style)
# VBox spacing
if _vbox:
_vbox.add_theme_constant_override("separation", 0)
# Propagate theme to existing children
for child in get_implant_children():
if child.has_method("apply_implant_theme"):
child.apply_implant_theme(theme_resource)
## Add an implant component to the panel.
func add_component(component: Control) -> void:
if not _vbox:
_vbox = VBoxContainer.new()
_vbox.name = "Content"
add_child(_vbox)
_vbox.add_child(component)
if theme_resource and component.has_method("apply_implant_theme"):
component.apply_implant_theme(theme_resource)
## Get all implant component children.
func get_implant_children() -> Array:
if not _vbox:
return []
return _vbox.get_children()
## Clear all components.
func clear() -> void:
if not _vbox:
return
for child in _vbox.get_children():
child.queue_free()
+28
View File
@@ -0,0 +1,28 @@
@tool
class_name ImplantSeparator
extends Control
## Thin horizontal separator line with themed spacing.
var _theme: ImplantTheme
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func apply_implant_theme(t: ImplantTheme) -> void:
_theme = t
custom_minimum_size.y = t.sep_above + t.sep_below
queue_redraw()
func _draw() -> void:
if not _theme:
return
var y: float = _theme.sep_above
draw_line(
Vector2(0, y),
Vector2(size.x, y),
_theme.separator,
1.0
)
+32
View File
@@ -0,0 +1,32 @@
@tool
class_name ImplantTextBlock
extends RichTextLabel
## Multi-line wrapping text block for narrative content (GTTR excerpts, etc.).
var _max_lines: int = -1
var _text_alpha: float = 0.75
func _init(content: String = "", max_lines: int = -1) -> void:
_max_lines = max_lines
text = content
bbcode_enabled = false
fit_content = true
scroll_active = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
if max_lines > 0:
visible_characters_behavior = TextServer.VC_GLYPHS_LTR
# Godot 4 RichTextLabel doesn't have max_lines directly —
# we limit via visible_ratio or custom_minimum_size after layout.
func set_content(content: String, max_lines: int = -1) -> void:
text = content.replace("**", "") # strip markdown bold
_max_lines = max_lines
func apply_implant_theme(t: ImplantTheme) -> void:
add_theme_font_size_override("normal_font_size", t.font_small)
add_theme_color_override("default_color",
Color(t.text_primary.r, t.text_primary.g, t.text_primary.b, _text_alpha))
+46
View File
@@ -0,0 +1,46 @@
class_name ImplantTheme
extends Resource
## Shared styling resource for all implant UI panels (D-169).
## Swap this resource at runtime to change the implant's visual identity
## (hardware variants, upgrades, faction overlays).
# ── Colors ───────────────────────────────────────────────────────────────────
## Primary readable text
@export var text_primary: Color = Color("#c8d0e0")
## Labels, metadata, captions
@export var text_dim: Color = Color("#667788")
## Selected, active, interactive highlight
@export var accent_active: Color = Color("#f0d060")
## Good state, opportunity, profit
@export var accent_positive: Color = Color("#44aa66")
## Danger, loss, hostile
@export var accent_negative: Color = Color("#aa4444")
## Caution, degraded, unknown
@export var accent_warning: Color = Color("#aa8844")
## Panel background
@export var panel_bg: Color = Color(0.05, 0.08, 0.14, 0.92)
## Panel border and separator lines
@export var separator: Color = Color(0.78, 0.82, 0.88, 0.12)
## Hover state background
@export var hover_bg: Color = Color(0.78, 0.82, 0.88, 0.08)
# ── Spacing ──────────────────────────────────────────────────────────────────
## Line height for single-line labels (baseline to baseline)
@export var line_height: int = 18
## Space from last text baseline to separator line
@export var sep_above: int = 6
## Space from separator line to next text baseline
@export var sep_below: int = 12
## Panel internal padding
@export var padding: int = 12
## Border width
@export var border_width: float = 1.0
# ── Font sizes ───────────────────────────────────────────────────────────────
@export var font_header: int = 15
@export var font_body: int = 11
@export var font_small: int = 10
@export var font_caption: int = 9
+44 -109
View File
@@ -35,12 +35,9 @@ const HIT_RADIUS: float = 10.0 # click tolerance
const EDGE_WIDTH: float = 0.8
const EDGE_SELECTED_ALPHA: float = 0.55
# Info popup — expanded with wiki/GTTR content (#780)
# Info popup — uses ImplantPanel component library (D-169)
const POPUP_WIDTH: float = 300.0
const POPUP_MARGIN: float = 16.0
const POPUP_PADDING: float = 12.0
const POPUP_LINE_H: float = 17.0
const POPUP_GTTR_FONT_SIZE: int = 10
const POPUP_GTTR_MAX_LINES: int = 7
# Colors — sector palette from wireframe
@@ -51,7 +48,6 @@ const COLOR_GATEWAY: Color = Color("#f0d060")
const COLOR_SELECTION: Color = Color("#f0d060")
const COLOR_TEXT: Color = Color("#c8d0e0")
const COLOR_TEXT_DIM: Color = Color("#667788")
const COLOR_INFO_BG: Color = Color(0.05, 0.08, 0.14, 0.92)
const SECTOR_COLORS: Dictionary = {
"core": Color("#c8d0e0"),
@@ -104,10 +100,23 @@ var _pan_start_offset: Vector2 = Vector2.ZERO
var _data_loaded: bool = false
var _insert_active: bool = true
var _dirty: bool = true # redraw needed — set by state changes, cleared after _draw
var _info_panel: ImplantPanel # D-169: component-based info panel
var _implant_theme: ImplantTheme
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_STOP
# D-169: Load implant theme and create info panel
_implant_theme = load("res://ui/implant/default_implant.tres") as ImplantTheme
_info_panel = ImplantPanel.new()
_info_panel.name = "InfoPanel"
_info_panel.theme_resource = _implant_theme
_info_panel.custom_minimum_size.x = POPUP_WIDTH
_info_panel.visible = false
_info_panel.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_info_panel)
_load_data()
if _data_loaded:
_compute_layout()
@@ -307,9 +316,12 @@ func _draw() -> void:
if _selected_system != "":
_draw_selection(center)
# Info panel for selected system
if _selected_system != "":
_draw_info_panel(sz)
# Info panel positioned in top-right (D-169 component panel)
if _info_panel:
_info_panel.visible = _selected_system != ""
if _info_panel.visible:
_info_panel.position = Vector2(
sz.x - POPUP_WIDTH - POPUP_MARGIN, POPUP_MARGIN)
# Title
_draw_title()
@@ -395,25 +407,26 @@ func _draw_selection(center: Vector2) -> void:
HORIZONTAL_ALIGNMENT_LEFT, -1, 12, COLOR_SELECTION)
func _draw_info_panel(sz: Vector2) -> void:
## Rebuild the info panel with components for the selected system (D-169).
func _rebuild_info_panel() -> void:
if not _info_panel:
return
_info_panel.clear()
var node: Dictionary = _node_lookup.get(_selected_system, {})
if node.is_empty():
_info_panel.visible = false
return
var font := get_theme_default_font()
var panel_w: float = POPUP_WIDTH
var pad: float = POPUP_PADDING
var line_h: float = 18.0 # line height for single-line text (baseline to baseline)
var sep_above: float = 6.0 # space from last text baseline to separator line
var sep_below: float = 12.0 # space from separator line to next text baseline
var inner_w: float = panel_w - pad * 2.0
var sep_color := Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.12)
# ── Prepare all text content before measuring ────────────────────────────
# ── Header ───────────────────────────────────────────────────────────────
var sys_name: String = node.get("proper_name", "")
if sys_name.is_empty():
sys_name = node.get("system_id", "Unknown")
_info_panel.add_component(ImplantHeader.new(sys_name, node.get("system_id", "")))
_info_panel.add_component(ImplantSeparator.new())
# ── Stats ────────────────────────────────────────────────────────────────
var star_type: String = node.get("star_type", "")
var hop: int = int(node.get("hop_distance", 0))
var stat_line_1: String
@@ -421,9 +434,11 @@ func _draw_info_panel(sz: Vector2) -> void:
stat_line_1 = "Hop %d from Gateway" % hop
else:
stat_line_1 = "%s star · hop %d" % [star_type, hop]
_info_panel.add_component(ImplantDataRow.new(stat_line_1))
var sector_str: String = node.get("geographic_sector", "unknown").replace("_", " ").to_upper()
var sector_color: Color = SECTOR_COLORS.get(node.get("geographic_sector", ""), COLOR_TEXT_DIM)
_info_panel.add_component(ImplantDataRow.new(sector_str + " corridor", sector_color))
var bodies: String = node.get("bodies", "")
var population: String = node.get("population", "")
@@ -438,105 +453,24 @@ func _draw_info_panel(sz: Vector2) -> void:
stat_line_3 = "%d aperture%s" % [
int(node.get("aperture_count", 0)),
"s" if int(node.get("aperture_count", 0)) != 1 else ""]
_info_panel.add_component(ImplantDataRow.new(stat_line_3))
var gttr: String = node.get("gttr_excerpt", "").replace("**", "")
# ── GTTR excerpt ─────────────────────────────────────────────────────────
var gttr: String = node.get("gttr_excerpt", "")
if not gttr.is_empty():
_info_panel.add_component(ImplantSeparator.new())
_info_panel.add_component(ImplantTextBlock.new(gttr, POPUP_GTTR_MAX_LINES))
# ── Adjacent systems ─────────────────────────────────────────────────────
var adj: Array = node.get("adjacent_systems", [])
var adj_line: String = ""
if not adj.is_empty():
_info_panel.add_component(ImplantSeparator.new())
var adj_names: Array = []
for neighbor_id: String in adj:
var neighbor: Dictionary = _node_lookup.get(neighbor_id, {})
var n_name: String = neighbor.get("proper_name", "")
adj_names.append(n_name if not n_name.is_empty() else neighbor_id)
adj_line = " · ".join(adj_names)
# ── Measure variable-height blocks ───────────────────────────────────────
var gttr_h: float = 0.0
if not gttr.is_empty():
gttr_h = font.get_multiline_string_size(
gttr, HORIZONTAL_ALIGNMENT_LEFT, inner_w,
POPUP_GTTR_FONT_SIZE, POPUP_GTTR_MAX_LINES).y
var adj_h: float = POPUP_LINE_H # minimum one line
if not adj_line.is_empty():
adj_h = font.get_multiline_string_size(
adj_line, HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, 3).y
# ── Calculate total panel height ─────────────────────────────────────────
var sep_total: float = sep_above + sep_below # full separator block height
var panel_h: float = pad # top padding
panel_h += line_h # system name (15px font)
panel_h += line_h # system id (10px font)
panel_h += sep_total # separator
panel_h += line_h # star type + hop
panel_h += line_h # corridor
panel_h += line_h # bodies + pop
if not gttr.is_empty():
panel_h += sep_total + gttr_h
panel_h += sep_total + adj_h # adjacent systems
panel_h += pad # bottom padding
# ── Draw background ──────────────────────────────────────────────────────
var panel_pos := Vector2(sz.x - panel_w - POPUP_MARGIN, POPUP_MARGIN)
var x: float = panel_pos.x + pad
var x_right: float = panel_pos.x + panel_w - pad
var y: float = panel_pos.y + pad + line_h # first baseline
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), COLOR_INFO_BG)
draw_rect(Rect2(panel_pos, Vector2(panel_w, panel_h)), sep_color, false, 1.0)
# ── Draw content ─────────────────────────────────────────────────────────
# System name
draw_string(font, Vector2(x, y), sys_name,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 15, COLOR_TEXT)
y += line_h
# System ID
draw_string(font, Vector2(x, y), node.get("system_id", ""),
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
# Separator — space above from baseline, line, space below to next baseline
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
# Star type + hop
draw_string(font, Vector2(x, y), stat_line_1,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
y += line_h
# Corridor
draw_string(font, Vector2(x, y), sector_str + " corridor",
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, sector_color)
y += line_h
# Bodies + population
draw_string(font, Vector2(x, y), stat_line_3,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 11, COLOR_TEXT_DIM)
# GTTR excerpt
if not gttr.is_empty():
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
draw_multiline_string(font, Vector2(x, y), gttr,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, POPUP_GTTR_FONT_SIZE,
POPUP_GTTR_MAX_LINES,
Color(COLOR_TEXT.r, COLOR_TEXT.g, COLOR_TEXT.b, 0.75))
y += gttr_h - line_h # multiline size includes first baseline
# Adjacent systems
y += sep_above
draw_line(Vector2(x, y), Vector2(x_right, y), sep_color, 1.0)
y += sep_below
if adj_line.is_empty():
draw_string(font, Vector2(x, y), "No gate connections",
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, COLOR_TEXT_DIM)
else:
draw_multiline_string(font, Vector2(x, y), adj_line,
HORIZONTAL_ALIGNMENT_LEFT, inner_w, 10, 3, COLOR_TEXT_DIM)
_info_panel.add_component(ImplantDataRow.new(" · ".join(adj_names)))
func _draw_title() -> void:
@@ -613,6 +547,7 @@ func _find_nearest_system(pos: Vector2) -> String:
func _handle_click(pos: Vector2) -> void:
var nearest := _find_nearest_system(pos)
_selected_system = nearest
_rebuild_info_panel()
_dirty = true