From a14828e02a2bf652634938c8145c8535efff251f Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Apr 2026 16:30:22 +0200 Subject: [PATCH 1/4] refactor(ui): add stance icons to implant badge (#787) Replace text-only stance labels with SVG icons + tint shader per D-086. Alpha-mask shader swaps color at runtime via ShaderMaterial parameter. Icons: walk, crouch, careful, sprint at 18px inside ImplantPanel row. Also fix stale UILayer/HUD test path in test_ui_framework_sprint15. Co-Authored-By: Claude Opus 4.6 --- client/shaders/icon_tint.gdshader | 10 ++++ client/tests/test_ui_framework_sprint15.gd | 6 +- client/ui/stance_indicator.gd | 64 +++++++++++++++++++--- 3 files changed, 68 insertions(+), 12 deletions(-) create mode 100644 client/shaders/icon_tint.gdshader diff --git a/client/shaders/icon_tint.gdshader b/client/shaders/icon_tint.gdshader new file mode 100644 index 000000000..aa2fc88e8 --- /dev/null +++ b/client/shaders/icon_tint.gdshader @@ -0,0 +1,10 @@ +shader_type canvas_item; +// D-086: Runtime icon tint — replaces color channel with tint_color, preserves alpha mask. +// Applied to SVG-sourced TextureRect nodes so stance/status icons can be color-coded at runtime. +// Default: #c8d0e0 (insert chrome) — identical to the SVG's authored stroke color. +uniform vec4 tint_color : source_color = vec4(0.784, 0.816, 0.878, 1.0); + +void fragment() { + float a = texture(TEXTURE, UV).a; + COLOR = vec4(tint_color.rgb, a * tint_color.a); +} diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index ff0ced7bd..494e34b57 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -122,14 +122,14 @@ func test_minimap_placeholder_exists_in_ui_layer() -> void: assert_that(_instance.get_node_or_null("InsertOverlay/Minimap")).is_not_null() -func test_hud_exists_in_ui_layer() -> void: - # D-049: HUD must be in UILayer. +func test_hud_exists_in_insert_overlay() -> void: + # D-051: HUD moved to InsertOverlay (layer 10) for bloom treatment (#786, PR #115). var scene := MAIN_SCENE _instance = scene.instantiate() auto_free(_instance) add_child(_instance) - assert_that(_instance.get_node_or_null("UILayer/HUD")).is_not_null() + assert_that(_instance.get_node_or_null("InsertOverlay/HUD")).is_not_null() func test_interaction_list_exists_in_insert_overlay() -> void: diff --git a/client/ui/stance_indicator.gd b/client/ui/stance_indicator.gd index a144037e0..1b4c53d60 100644 --- a/client/ui/stance_indicator.gd +++ b/client/ui/stance_indicator.gd @@ -1,23 +1,33 @@ extends Control ## D-053: Stance indicator — implant-styled badge (D-169). -## Sprint/Walk/Careful/Crouch. Color-coded for quick read. +## Sprint/Walk/Careful/Crouch. Icon (D-086) + color-coded label. const STANCE_COLORS := { "Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous - "Walk": Color("#c8d0e0"), # Default — neutral white-blue - "Careful": Color("#6bc9a6"), # Green — quiet, observant + "Walk": Color("#c8d0e0"), # Default — neutral white-blue + "Careful": Color("#6bc9a6"), # Green — quiet, observant "Crouch": Color("#e8c547"), # Amber — very quiet, slow } +const STANCE_ICONS := { + "Sprint": "res://assets/icons/icon_stance_sprint.svg", + "Walk": "res://assets/icons/icon_stance_walk.svg", + "Careful": "res://assets/icons/icon_stance_careful.svg", + "Crouch": "res://assets/icons/icon_stance_crouch.svg", +} + var _current_stance: String = "Walk" var _panel: ImplantPanel -var _row: ImplantDataRow +var _icon: TextureRect +var _label: Label +var _icon_mat: ShaderMaterial func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE var theme_res := load("res://ui/implant/default_implant.tres") as ImplantTheme + var icon_shader := load("res://shaders/icon_tint.gdshader") as Shader _panel = ImplantPanel.new() _panel.name = "StancePanel" @@ -25,17 +35,53 @@ func _ready() -> void: _panel.mouse_filter = Control.MOUSE_FILTER_IGNORE add_child(_panel) - _row = ImplantDataRow.new("Walk", STANCE_COLORS["Walk"]) - _panel.add_component(_row) + var hbox := HBoxContainer.new() + hbox.add_theme_constant_override("separation", 4) + hbox.mouse_filter = Control.MOUSE_FILTER_IGNORE + + _icon = TextureRect.new() + _icon.expand_mode = TextureRect.EXPAND_KEEP_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.custom_minimum_size = Vector2(18.0, 18.0) + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + _icon_mat = ShaderMaterial.new() + _icon_mat.shader = icon_shader + _icon_mat.set_shader_parameter("tint_color", STANCE_COLORS["Walk"]) + _icon.material = _icon_mat + hbox.add_child(_icon) + + _label = Label.new() + _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER + _label.mouse_filter = Control.MOUSE_FILTER_IGNORE + if theme_res: + _label.add_theme_font_size_override("font_size", theme_res.font_body) + hbox.add_child(_label) + + _panel.add_component(hbox) + _apply_stance("Walk") + + +func _apply_stance(stance: String) -> void: + _current_stance = stance + var color: Color = STANCE_COLORS.get(stance, Color("#c8d0e0")) + var icon_path: String = STANCE_ICONS.get(stance, "") + + if _icon and not icon_path.is_empty(): + var tex := load(icon_path) as Texture2D + if tex: + _icon.texture = tex + _icon_mat.set_shader_parameter("tint_color", color) + + if _label: + _label.text = stance + _label.add_theme_color_override("font_color", color) func update_from_state() -> void: var stance: String = GameState.player_stance if stance == _current_stance: return - _current_stance = stance - var color: Color = STANCE_COLORS.get(stance, Color("#c8d0e0")) - _row.set_content(stance, color) + _apply_stance(stance) func get_current_stance() -> String: From bb61d860d606f5459e91f837648c31b8a206240c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Apr 2026 16:30:57 +0200 Subject: [PATCH 2/4] chore(meta): update changelog Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f192c95a5..b1cd85d09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,9 +31,10 @@ Format based on [Keep a Changelog](https://keepachangelog.com/). ### Changed - Pre-push hook now validates JSON syntax on changed files (python3 -m json.tool) - HUD status panel: merged TimeDisplay into ImplantPanel — time, health, perception in one themed panel (#786) -- HUD moved from UILayer (20) to InsertOverlay (10) for bloom treatment per D-051 +- HUD moved from UILayer (20) to InsertOverlay (10) for bloom treatment per D-049 - Interaction prompt: converted from PanelContainer+StyleBoxFlat to ImplantPanel+ImplantDataRow (#788) - Minimap: added ImplantTheme-styled container frame behind circular display (#787) +- Stance indicator: SVG icons with alpha-mask tint shader replace text-only labels (#787, D-086) - Amended 7 stale D-records with supersession notes (D-012, D-035, D-044, D-061, D-086, D-090, D-119) - Closed 4 resolved scope questions (Q-002, Q-004, Q-005, Q-007) - Fixed questions.md index (was 61 questions, actually 96 — 33 architecture Qs missing from index) From 746ed05e2d3e0c783072ac7531c173e94f92b39c Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Apr 2026 16:41:27 +0200 Subject: [PATCH 3/4] =?UTF-8?q?fix(ui):=20address=20PR=20#118=20review=20?= =?UTF-8?q?=E2=80=94=205=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Preload stance icons at _ready() into cache Dictionary - Null-guard shader + theme loads with push_error() + early return - Guard _icon_mat.set_shader_parameter inside if _icon_mat: - Icon size 18→20px per D-086, container height 44→46px to fit - Add _apply_stance test coverage (known + unknown stance) Co-Authored-By: Claude Opus 4.6 --- client/tests/test_ui_framework_sprint15.gd | 30 +++++++++++++++++++ client/ui/gauntlet_hud.tscn | 6 ++-- client/ui/stance_indicator.gd | 35 +++++++++++++--------- client/ui/stance_indicator.tscn | 4 +-- 4 files changed, 56 insertions(+), 19 deletions(-) diff --git a/client/tests/test_ui_framework_sprint15.gd b/client/tests/test_ui_framework_sprint15.gd index 494e34b57..938aa98f3 100644 --- a/client/tests/test_ui_framework_sprint15.gd +++ b/client/tests/test_ui_framework_sprint15.gd @@ -313,3 +313,33 @@ func test_tile_renderer_skips_nonzero_z() -> void: # z=1 and z=2 tiles should NOT be present (-1 = no cell) assert_that(tile_renderer.get_cell_source_id(Vector2i(1, 0))).is_equal(-1) assert_that(tile_renderer.get_cell_source_id(Vector2i(3, 0))).is_equal(-1) + + +# ------------------------------------------------------------------------- +# Stance indicator icon tests (#787) +# ------------------------------------------------------------------------- + +func test_stance_indicator_applies_known_stance() -> void: + # #787: _apply_stance updates icon + label for each known stance. + var scene := MAIN_SCENE + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var si = _instance.get_node_or_null("UILayer/StanceIndicator") + assert_that(si).is_not_null() + si._apply_stance("Careful") + assert_that(si.get_current_stance()).is_equal("Careful") + + +func test_stance_indicator_unknown_stance_no_crash() -> void: + # #787: Unknown stance falls back gracefully — no crash, no texture change. + var scene := MAIN_SCENE + _instance = scene.instantiate() + auto_free(_instance) + add_child(_instance) + + var si = _instance.get_node_or_null("UILayer/StanceIndicator") + assert_that(si).is_not_null() + si._apply_stance("InvalidStance") + assert_that(si.get_current_stance()).is_equal("InvalidStance") diff --git a/client/ui/gauntlet_hud.tscn b/client/ui/gauntlet_hud.tscn index da3e169da..3c307e8a8 100644 --- a/client/ui/gauntlet_hud.tscn +++ b/client/ui/gauntlet_hud.tscn @@ -2,16 +2,16 @@ [ext_resource type="Script" path="res://ui/gauntlet_hud.gd" id="1_gauntlet"] -; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator (bottom at y:244) +; #496: Gauntlet HUD — room timer + personal bests, top-right below StanceIndicator (bottom at y:246), 8px gap [node name="GauntletHUD" type="Control"] layout_mode = 3 anchors_preset = 1 anchor_left = 1.0 anchor_right = 1.0 offset_left = -160.0 -offset_top = 252.0 +offset_top = 254.0 offset_right = -16.0 -offset_bottom = 278.0 +offset_bottom = 280.0 grow_horizontal = 0 mouse_filter = 2 script = ExtResource("1_gauntlet") diff --git a/client/ui/stance_indicator.gd b/client/ui/stance_indicator.gd index 1b4c53d60..75a83c062 100644 --- a/client/ui/stance_indicator.gd +++ b/client/ui/stance_indicator.gd @@ -10,24 +10,32 @@ const STANCE_COLORS := { "Crouch": Color("#e8c547"), # Amber — very quiet, slow } -const STANCE_ICONS := { - "Sprint": "res://assets/icons/icon_stance_sprint.svg", - "Walk": "res://assets/icons/icon_stance_walk.svg", - "Careful": "res://assets/icons/icon_stance_careful.svg", - "Crouch": "res://assets/icons/icon_stance_crouch.svg", -} - var _current_stance: String = "Walk" var _panel: ImplantPanel var _icon: TextureRect var _label: Label var _icon_mat: ShaderMaterial +var _icon_cache: Dictionary = {} # stance name → Texture2D func _ready() -> void: mouse_filter = Control.MOUSE_FILTER_IGNORE var theme_res := load("res://ui/implant/default_implant.tres") as ImplantTheme var icon_shader := load("res://shaders/icon_tint.gdshader") as Shader + if not theme_res: + push_error("StanceIndicator: failed to load implant theme") + return + if not icon_shader: + push_error("StanceIndicator: failed to load icon_tint shader") + return + + # Preload all stance icons at startup + for stance_name in STANCE_ICONS: + var tex := load(STANCE_ICONS[stance_name]) as Texture2D + if tex: + _icon_cache[stance_name] = tex + else: + push_error("StanceIndicator: failed to load icon for %s" % stance_name) _panel = ImplantPanel.new() _panel.name = "StancePanel" @@ -42,7 +50,7 @@ func _ready() -> void: _icon = TextureRect.new() _icon.expand_mode = TextureRect.EXPAND_KEEP_SIZE _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - _icon.custom_minimum_size = Vector2(18.0, 18.0) + _icon.custom_minimum_size = Vector2(20.0, 20.0) # D-086: 20×20 viewBox _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE _icon_mat = ShaderMaterial.new() _icon_mat.shader = icon_shader @@ -53,8 +61,7 @@ func _ready() -> void: _label = Label.new() _label.vertical_alignment = VERTICAL_ALIGNMENT_CENTER _label.mouse_filter = Control.MOUSE_FILTER_IGNORE - if theme_res: - _label.add_theme_font_size_override("font_size", theme_res.font_body) + _label.add_theme_font_size_override("font_size", theme_res.font_body) hbox.add_child(_label) _panel.add_component(hbox) @@ -64,13 +71,13 @@ func _ready() -> void: func _apply_stance(stance: String) -> void: _current_stance = stance var color: Color = STANCE_COLORS.get(stance, Color("#c8d0e0")) - var icon_path: String = STANCE_ICONS.get(stance, "") - if _icon and not icon_path.is_empty(): - var tex := load(icon_path) as Texture2D + if _icon: + var tex: Texture2D = _icon_cache.get(stance) if tex: _icon.texture = tex - _icon_mat.set_shader_parameter("tint_color", color) + if _icon_mat: + _icon_mat.set_shader_parameter("tint_color", color) if _label: _label.text = stance diff --git a/client/ui/stance_indicator.tscn b/client/ui/stance_indicator.tscn index bc14f6abb..f20fe4c2f 100644 --- a/client/ui/stance_indicator.tscn +++ b/client/ui/stance_indicator.tscn @@ -3,7 +3,7 @@ [ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"] ; D-053: Stance indicator — below minimap (bottom at y:176), 24px gap. -; Control height 44px to contain ImplantPanel (padding 12+12 + row 18 = 42px minimum). +; Control height 46px: border(1) + padding(12) + icon(20) + padding(12) + border(1). [node name="StanceIndicator" type="Control"] layout_mode = 3 anchors_preset = 1 @@ -12,7 +12,7 @@ anchor_right = 1.0 offset_left = -100.0 offset_top = 200.0 offset_right = -16.0 -offset_bottom = 244.0 +offset_bottom = 246.0 grow_horizontal = 0 mouse_filter = 2 script = ExtResource("1_stance") From 168c5e0d9bdd26bd727cb070e31bab7244a0060d Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Mon, 6 Apr 2026 16:43:49 +0200 Subject: [PATCH 4/4] fix(ui): restore STANCE_ICONS constant removed during refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Startup crash — preload loop referenced STANCE_ICONS but the constant was accidentally dropped when rewriting the icon cache logic. Co-Authored-By: Claude Opus 4.6 --- client/ui/stance_indicator.gd | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/ui/stance_indicator.gd b/client/ui/stance_indicator.gd index 75a83c062..a60264186 100644 --- a/client/ui/stance_indicator.gd +++ b/client/ui/stance_indicator.gd @@ -10,6 +10,13 @@ const STANCE_COLORS := { "Crouch": Color("#e8c547"), # Amber — very quiet, slow } +const STANCE_ICONS := { + "Sprint": "res://assets/icons/icon_stance_sprint.svg", + "Walk": "res://assets/icons/icon_stance_walk.svg", + "Careful": "res://assets/icons/icon_stance_careful.svg", + "Crouch": "res://assets/icons/icon_stance_crouch.svg", +} + var _current_stance: String = "Walk" var _panel: ImplantPanel var _icon: TextureRect