diff --git a/client/ui/implant/default_implant.tres b/client/ui/implant/default_implant.tres new file mode 100644 index 000000000..dbde1e229 --- /dev/null +++ b/client/ui/implant/default_implant.tres @@ -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 diff --git a/client/ui/implant/implant_data_row.gd b/client/ui/implant/implant_data_row.gd new file mode 100644 index 000000000..d04371194 --- /dev/null +++ b/client/ui/implant/implant_data_row.gd @@ -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 diff --git a/client/ui/implant/implant_header.gd b/client/ui/implant/implant_header.gd new file mode 100644 index 000000000..03144b5ed --- /dev/null +++ b/client/ui/implant/implant_header.gd @@ -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) diff --git a/client/ui/implant/implant_panel.gd b/client/ui/implant/implant_panel.gd new file mode 100644 index 000000000..6f14e1354 --- /dev/null +++ b/client/ui/implant/implant_panel.gd @@ -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() diff --git a/client/ui/implant/implant_separator.gd b/client/ui/implant/implant_separator.gd new file mode 100644 index 000000000..6150dd65f --- /dev/null +++ b/client/ui/implant/implant_separator.gd @@ -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 + ) diff --git a/client/ui/implant/implant_text_block.gd b/client/ui/implant/implant_text_block.gd new file mode 100644 index 000000000..a9227fd4a --- /dev/null +++ b/client/ui/implant/implant_text_block.gd @@ -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)) diff --git a/client/ui/implant/implant_theme.gd b/client/ui/implant/implant_theme.gd new file mode 100644 index 000000000..ce2efccf1 --- /dev/null +++ b/client/ui/implant/implant_theme.gd @@ -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 diff --git a/client/ui/star_map.gd b/client/ui/star_map.gd index 7dbb9a536..61ba2c857 100644 --- a/client/ui/star_map.gd +++ b/client/ui/star_map.gd @@ -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