#112: every skill node carries an icon

A node that unlocks a structure borrows that structure's own menu icon, so
the tree and the build menu cannot disagree about what a shipyard looks
like. Doctrines and permanent bonuses, which have no building to point at,
carry an explicit one.

Drawn as child IconButtons with their frame turned off rather than
reimplemented in the tree's _draw: the glyphs already exist there and are
not trivial, and a dozen of them copied here would be two places to change
every time one is redrawn. They are inert - MOUSE_FILTER_IGNORE means the
tree's raw-input handling still sees every press, exactly as it did when
there was nothing on top of the nodes - and hidden rather than clipped when
scrolled off the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 21:59:27 +02:00
commit 551360f2e9
2 changed files with 90 additions and 2 deletions

View file

@ -55,6 +55,8 @@ enum Icon {
OUTPOST,
# #106: appended, same rule.
PLANNING_CENTER,
# #116: the workshop and the drone it sends out. Appended, same rule.
WORKSHOP, REPAIR_DRONE,
}
# Size the glyphs were originally drawn against. Every glyph dimension is
@ -67,6 +69,11 @@ const REFERENCE_SIZE: float = 56.0
# Selection is an additional frame, never a recolour. It lets menus retain
# semantic colours (vessel class, danger, cargo) while still making the
# active choice unmistakable.
# #112: the glyph without the button around it. The skill tree draws its own
# hexagon for each node and wants only what is inside one, and a second frame
# nested in that hexagon reads as a button sitting on a node rather than as
# the node's own mark.
@export var show_frame: bool = true
@export var highlighted: bool = false: set = set_highlighted
# Colour of the bright core stroke. Defaults to near-white, which reads well
# on the dark space background; scenes drawing over a bright backdrop (the
@ -206,8 +213,10 @@ func _draw() -> void:
var pulse: float = 1.0 if pressed_down else 0.85
var margin: float = 4.0 * s
NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(), pulse, core_color)
if highlighted:
if show_frame:
NeonDraw.draw_glowing_polygon(self, _frame_points(margin), _active_color(),
pulse, core_color)
if highlighted and show_frame:
# The inset frame reads as a selection focus ring. It deliberately uses
# the bright core rather than the button's glow colour, so selecting a
# blue shield or pink transporter does not change what that colour means.
@ -300,6 +309,10 @@ func _draw() -> void:
_draw_outpost_icon(center, pulse, s)
Icon.PLANNING_CENTER:
_draw_planning_icon(center, pulse, s)
Icon.WORKSHOP:
_draw_workshop_icon(center, pulse, s)
Icon.REPAIR_DRONE:
_draw_repair_drone_icon(center, pulse, s)
func _draw_close(center: Vector2, pulse: float, s: float) -> void:
@ -668,6 +681,40 @@ func _draw_ship_lab_icon(center: Vector2, pulse: float, s: float) -> void:
_active_color(), pulse, 0.35 * s, core_color)
func _draw_workshop_icon(center: Vector2, pulse: float, s: float) -> void:
# A wrench crossed with a girder. Nothing else in the set is a tool, and
# the meaning wanted is "things are mended here" - the crafter's cradle
# already owns "things are made here".
var arm: float = 11.0 * s
NeonDraw.draw_glowing_line(self, center + Vector2(-arm, arm),
center + Vector2(arm * 0.5, -arm * 0.5), _active_color(), pulse, 0.5 * s, core_color)
# The jaws.
NeonDraw.draw_glowing_path(self, PackedVector2Array([
center + Vector2(arm * 0.2, -arm * 0.95),
center + Vector2(arm * 0.95, -arm * 0.8),
center + Vector2(arm * 0.8, -arm * 0.1),
center + Vector2(arm * 0.15, -arm * 0.3),
]), _active_color(), pulse, 0.4 * s, core_color)
# The girder it is working on.
NeonDraw.draw_glowing_line(self, center + Vector2(-arm, -arm * 0.35),
center + Vector2(arm * 0.1, arm), _active_color(), pulse * 0.7, 0.35 * s, core_color)
func _draw_repair_drone_icon(center: Vector2, pulse: float, s: float) -> void:
# A small hull with a tool arm out in front, so it reads as a tender
# rather than as another fighter.
var r: float = 9.0 * s
NeonDraw.draw_glowing_polygon(self, PackedVector2Array([
center + Vector2(0.0, -r), center + Vector2(r * 0.9, 0.0),
center + Vector2(r * 0.6, r), center + Vector2(-r * 0.6, r),
center + Vector2(-r * 0.9, 0.0),
]), _active_color(), pulse, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(0.0, -r),
center + Vector2(0.0, -r * 1.9), _active_color(), pulse, 0.35 * s, core_color)
NeonDraw.draw_glowing_line(self, center + Vector2(-r * 0.5, -r * 1.9),
center + Vector2(r * 0.5, -r * 1.9), _active_color(), pulse, 0.35 * s, core_color)
func _draw_planning_icon(center: Vector2, pulse: float, s: float) -> void:
# A tally board: a frame with three bars of different lengths inside it.
# Nothing else in the set is rectilinear and horizontal, and the meaning

View file

@ -69,8 +69,48 @@ func _max_scroll() -> float:
return maxf(0.0, _content_height() - size.y)
# #112: one glyph per node, as a child IconButton with its frame turned off.
# Drawn as nodes rather than into _draw() because the glyphs already exist
# there and are non-trivial - reimplementing a dozen of them here would be
# two places to change every time one is redrawn. They are inert:
# MOUSE_FILTER_IGNORE means the tree's own raw-input handling still sees
# every press, exactly as it did when there was nothing on top of the nodes.
var _glyphs: Dictionary = {}
func _build_glyphs() -> void:
for node in GameState.SKILL_TREE:
var icon: int = GameState.skill_node_icon(node)
if icon < 0:
continue
var glyph := IconButton.new()
glyph.icon = icon
glyph.show_frame = false
glyph.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(glyph)
_glyphs[String(node.id)] = glyph
func _layout_glyphs() -> void:
var span: float = node_radius * 1.5
for node in GameState.SKILL_TREE:
var glyph: Control = _glyphs.get(String(node.id))
if glyph == null:
continue
var centre: Vector2 = node_screen_pos(node)
glyph.position = centre - Vector2(span, span) * 0.5
glyph.size = Vector2(span, span)
# Off the top or bottom of the panel entirely: hidden rather than
# clipped, so a glyph never bleeds past the canvas the tree scrolls
# inside.
glyph.visible = centre.y > -span and centre.y < size.y + span
glyph.glow_color = _state_color(node)
glyph.queue_redraw()
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_glyphs()
call_deferred("reset_scroll")
@ -164,6 +204,7 @@ func handle_pan(relative: Vector2) -> bool:
func _draw() -> void:
_layout_glyphs()
# Links first, so nodes sit on top of them.
for node in GameState.SKILL_TREE:
if node.requires == "":