feat(client): Sprint 6 Touch — z-layer pipeline, cursor, fog, interactions, inventory, stance, radial

Three-scope z-layer rendering pipeline (D-049): world z:0-900 inside
CanvasGroup, insert overlay CanvasLayer 10, UI CanvasLayer 20, modal
CanvasLayer 30. Y-sort contract enforced (entities z_index=0). Reserved
ranges for VFX, airborne, lower floors documented in constants.gd.

Sprint 6 client tickets:
- #429: Cursor state machine — 4 states, 150ms transitions (D-056)
- #430: Fog shader rebuild — 5-layer fragment shader, animated noise (D-059)
- #432: Entity interaction list — vertical multi-verb, insert-styled (D-057)
- #433: World radial menu — 2 spokes, drag-release + click-click (D-058)
- #438: Inventory UI — 3x3 grid, 40x40px, 1-9 hotkeys (D-065)
- #439: Stance indicator — color-coded HUD, C/X keybinds (D-053)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-15 23:06:03 +01:00
co-authored by Claude Opus 4.6
parent 24cce41379
commit 1fcf08d21f
24 changed files with 2370 additions and 151 deletions
+8
View File
@@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="1_cursor"]
; D-056: Cursor state machine — standalone scene for testing.
; In the main scene, this is instantiated as a child of UILayer (z-layer 7).
[node name="CursorStateMachine" type="Node2D"]
script = ExtResource("1_cursor")
+169
View File
@@ -0,0 +1,169 @@
extends Control
## D-057: Entity interaction vertical list — insert-styled, z-layer 6.
## Compact list of 2-4 verb options, anchored to entity position.
## Sprint suppression (D-055): hidden while sprinting.
## Diegetic test: labels disappear when insert is off.
##
## Replaces the single-line interaction_prompt for multi-verb scenarios.
## Public API matches QA test contract (test_interaction_list.gd).
signal verb_selected(kind: String, entity_id: int)
const MAX_VERBS := 4
const FADE_IN := 0.12
const FADE_OUT := 0.10
const LABEL_HEIGHT := 22
const LABEL_GAP := 2
const INSERT_FG := Color("#c8d0e0")
const INSERT_DIM := Color("#8b8ba0")
const INSERT_BG := Color(0.05, 0.05, 0.08, 0.7)
var _showing: bool = false
var _insert_active: bool = true
var _current_target_id: int = -1
var _verb_items: Array = [] # sorted [{kind, label, priority, available}]
var _selected_index: int = 0
var _active_tween: Tween = null
var _verb_labels: Array[Label] = []
@onready var _vbox: VBoxContainer = $VBox
func _ready() -> void:
modulate.a = 0.0
visible = false
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
# D-055: Sprint stance suppresses interaction list
if GameState.player_stance == "Sprint":
_hide()
return
# Diegetic toggle — insert off means no overlay data
if not _insert_active:
_hide()
return
var interactions: Array = GameState.nearby_interactions
if interactions.is_empty():
_hide()
return
var interaction: Dictionary = interactions[0]
var entity_id: int = interaction.get("entity_id", -1)
var verbs: Array = interaction.get("verbs", [])
if verbs.is_empty():
_hide()
return
# Sort by priority ascending, cap at MAX_VERBS
var sorted: Array = verbs.duplicate()
sorted.sort_custom(func(a, b): return a.get("priority", 99) < b.get("priority", 99))
if sorted.size() > MAX_VERBS:
sorted = sorted.slice(0, MAX_VERBS)
_current_target_id = entity_id
_verb_items = sorted
_selected_index = 0
_rebuild_labels()
_show()
func _rebuild_labels() -> void:
# Clear existing labels
for lbl in _verb_labels:
if is_instance_valid(lbl):
lbl.queue_free()
_verb_labels.clear()
for i in range(_verb_items.size()):
var verb: Dictionary = _verb_items[i]
var lbl := Label.new()
lbl.text = verb.get("label", "")
lbl.add_theme_font_size_override("font_size", 14)
lbl.add_theme_color_override("font_color", INSERT_FG if i == _selected_index else INSERT_DIM)
lbl.horizontal_alignment = HORIZONTAL_ALIGNMENT_LEFT
lbl.mouse_filter = Control.MOUSE_FILTER_IGNORE
_vbox.add_child(lbl)
_verb_labels.append(lbl)
func _show() -> void:
if _showing:
return
_showing = true
visible = true
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 1.0, FADE_IN)
func _hide() -> void:
if not _showing:
return
_showing = false
_current_target_id = -1
_verb_items.clear()
if _active_tween and _active_tween.is_valid():
_active_tween.kill()
_active_tween = create_tween()
_active_tween.tween_property(self, "modulate:a", 0.0, FADE_OUT)
_active_tween.tween_callback(func():
visible = false
for lbl in _verb_labels:
if is_instance_valid(lbl):
lbl.queue_free()
_verb_labels.clear()
)
# -- Lazy sync for getters (tests may set GameState without calling update) --
func _ensure_synced() -> void:
if _current_target_id == -1 and not GameState.nearby_interactions.is_empty():
update_from_state()
# -- Public API (QA test contract) ------------------------------------------
func get_visible_verb_count() -> int:
_ensure_synced()
return _verb_items.size()
func is_showing() -> bool:
return _showing
func get_selected_verb() -> String:
_ensure_synced()
if _verb_items.is_empty():
return ""
return _verb_items[_selected_index].get("kind", "")
func get_interaction_target() -> int:
_ensure_synced()
return _current_target_id
func get_verb_labels() -> Array:
var labels: Array = []
for verb in _verb_items:
labels.append(verb.get("label", ""))
return labels
func set_insert_active(active: bool) -> void:
_insert_active = active
if not active and _showing:
_hide()
func get_z_layer() -> int:
return Constants.CANVAS_INSERT
+16
View File
@@ -0,0 +1,16 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/interaction_list.gd" id="1_list"]
[node name="InteractionList" type="Control"]
layout_mode = 3
anchors_preset = 0
mouse_filter = 2
script = ExtResource("1_list")
[node name="VBox" type="VBoxContainer" parent="."]
layout_mode = 0
offset_right = 160.0
offset_bottom = 100.0
mouse_filter = 2
theme_override_constants/separation = 2
+139
View File
@@ -0,0 +1,139 @@
extends Control
## D-065: Inventory grid — 3x3 slots, bottom-right, 40x40px icons.
## No empty slots displayed — icons appear only when items are carried.
## 1-9 hotkeys for direct slot access. Reads from GameState.player_inventory.
## Lives on UILayer (z-layer 7).
signal item_selected(slot: int, item_id: int)
const SLOT_SIZE := 40
const SLOT_GAP := 4
const GRID_COLS := 3
const GRID_ROWS := 3
const MAX_SLOTS := 9
const SLOT_BG := Color(0.1, 0.1, 0.14, 0.6)
const SLOT_BORDER := Color(0.3, 0.3, 0.38, 0.8)
const SLOT_ACTIVE := Color("#c8d0e0")
const SLOT_TEXT := Color("#c8d0e0")
const SLOT_TEXT_DIM := Color("#8b8ba0")
const HOTKEY_SIZE := 10
var _slots: Array[Dictionary] = [] # [{item_id, name, slot}] from GameState
var _slot_nodes: Array[Control] = []
var _selected_slot: int = -1
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
_build_grid()
visible = false
func _build_grid() -> void:
for node in _slot_nodes:
if is_instance_valid(node):
node.queue_free()
_slot_nodes.clear()
var grid_w: float = GRID_COLS * SLOT_SIZE + (GRID_COLS - 1) * SLOT_GAP
var grid_h: float = GRID_ROWS * SLOT_SIZE + (GRID_ROWS - 1) * SLOT_GAP
# Position grid at bottom-right with margin
custom_minimum_size = Vector2(grid_w, grid_h)
size = Vector2(grid_w, grid_h)
func update_from_state() -> void:
_slots = GameState.player_inventory.duplicate()
if _slots.is_empty():
visible = false
return
visible = true
queue_redraw()
func _draw() -> void:
if _slots.is_empty():
return
for item in _slots:
var slot_idx: int = item.get("slot", 0)
if slot_idx < 0 or slot_idx >= MAX_SLOTS:
continue
var col: int = slot_idx % GRID_COLS
var row: int = slot_idx / GRID_COLS
var pos := Vector2(
col * (SLOT_SIZE + SLOT_GAP),
row * (SLOT_SIZE + SLOT_GAP)
)
var rect := Rect2(pos, Vector2(SLOT_SIZE, SLOT_SIZE))
# Slot background
draw_rect(rect, SLOT_BG)
# Border
draw_rect(rect, SLOT_BORDER, false, 1.0)
# Selected highlight
if slot_idx == _selected_slot:
draw_rect(rect, SLOT_ACTIVE, false, 2.0)
# Item name (truncated, centered)
var item_name: String = item.get("name", "?")
if item_name.length() > 5:
item_name = item_name.substr(0, 4) + "."
var font := ThemeDB.fallback_font
var font_size := 11
var text_size := font.get_string_size(item_name, HORIZONTAL_ALIGNMENT_CENTER, -1, font_size)
var text_pos := pos + Vector2((SLOT_SIZE - text_size.x) / 2.0, SLOT_SIZE / 2.0 + text_size.y / 4.0)
draw_string(font, text_pos, item_name, HORIZONTAL_ALIGNMENT_LEFT, -1, font_size, SLOT_TEXT)
# Hotkey number (top-left corner)
var hotkey := str(slot_idx + 1)
draw_string(font, pos + Vector2(3, HOTKEY_SIZE + 2), hotkey, HORIZONTAL_ALIGNMENT_LEFT, -1, HOTKEY_SIZE, SLOT_TEXT_DIM)
func _unhandled_input(event: InputEvent) -> void:
if not visible:
return
if event is InputEventKey and event.pressed and not event.echo:
var key: int = event.physical_keycode
# 1-9 hotkeys (Key_1 = 49, Key_9 = 57)
if key >= KEY_1 and key <= KEY_9:
var slot_idx: int = key - KEY_1
_select_slot(slot_idx)
get_viewport().set_input_as_handled()
func _select_slot(slot_idx: int) -> void:
# Find item in this slot
for item in _slots:
if item.get("slot", -1) == slot_idx:
_selected_slot = slot_idx
item_selected.emit(slot_idx, item.get("item_id", -1))
queue_redraw()
return
# No item in slot — deselect
_selected_slot = -1
queue_redraw()
# -- Public API ---------------------------------------------------------------
func get_slot_count() -> int:
return _slots.size()
func get_selected_slot() -> int:
return _selected_slot
func get_item_at_slot(slot_idx: int) -> Dictionary:
for item in _slots:
if item.get("slot", -1) == slot_idx:
return item
return {}
+20
View File
@@ -0,0 +1,20 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/inventory_grid.gd" id="1_inv"]
; D-065: 3x3 inventory grid, bottom-right, 40x40px icons
[node name="InventoryGrid" type="Control"]
layout_mode = 3
anchors_preset = 3
anchor_left = 1.0
anchor_top = 1.0
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = -148.0
offset_top = -148.0
offset_right = -16.0
offset_bottom = -16.0
grow_horizontal = 0
grow_vertical = 0
mouse_filter = 2
script = ExtResource("1_inv")
+51
View File
@@ -0,0 +1,51 @@
extends Control
## D-053: Stance indicator — shows current movement stance on HUD.
## Sprint/Walk/Careful/Crouch. Color-coded for quick read.
## Lives on UILayer (z-layer 7).
const STANCE_COLORS := {
"Sprint": Color("#d45d5d"), # Red — fast, loud, dangerous
"Walk": Color("#c8d0e0"), # Default — neutral white-blue
"Careful": Color("#6bc9a6"), # Green — quiet, observant
"Crouch": Color("#e8c547"), # Amber — very quiet, slow
}
const STANCE_DEFAULT_COLOR := Color("#c8d0e0")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.5)
const FONT_SIZE := 13
const PADDING := Vector2(10, 6)
var _current_stance: String = "Walk"
func _ready() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
func update_from_state() -> void:
var stance: String = GameState.player_stance
if stance == _current_stance:
return
_current_stance = stance
queue_redraw()
func _draw() -> void:
var font := ThemeDB.fallback_font
var text := _current_stance
var text_size := font.get_string_size(text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE)
var box_size := text_size + PADDING * 2
# Background
draw_rect(Rect2(Vector2.ZERO, box_size), BG_COLOR)
# Stance text
var color: Color = STANCE_COLORS.get(_current_stance, STANCE_DEFAULT_COLOR)
draw_string(font, PADDING + Vector2(0, text_size.y), text, HORIZONTAL_ALIGNMENT_LEFT, -1, FONT_SIZE, color)
# -- Public API ---------------------------------------------------------------
func get_current_stance() -> String:
return _current_stance
+17
View File
@@ -0,0 +1,17 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/stance_indicator.gd" id="1_stance"]
; D-053: Stance indicator — top-right HUD element
[node name="StanceIndicator" type="Control"]
layout_mode = 3
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -100.0
offset_top = 16.0
offset_right = -16.0
offset_bottom = 42.0
grow_horizontal = 0
mouse_filter = 2
script = ExtResource("1_stance")
+212
View File
@@ -0,0 +1,212 @@
extends Control
## D-058: World radial menu — right-click, 2 spokes v0.1 (Observe + Insert).
## Insert-styled: geometric lines, thin spokes, nearly transparent.
## Renders on InsertOverlay (CanvasLayer 10).
## Drag-release for power users, click-click for newcomers.
## Insert spoke sends Pause on activate, Pause again on close (toggle).
signal spoke_selected(spoke_name: String)
enum Spoke { NONE, OBSERVE, INSERT }
const SPOKE_RADIUS := 60.0
const INNER_RADIUS := 16.0
const LINE_COLOR := Color("#c8d0e0")
const LINE_DIM := Color(0.78, 0.82, 0.88, 0.3)
const HOVER_COLOR := Color("#e0e8ff")
const BG_COLOR := Color(0.05, 0.05, 0.08, 0.35)
const LINE_WIDTH := 1.5
const ICON_SIZE := 12.0
# Spoke angles: Observe = up (270°), Insert = down (90°)
const SPOKE_ANGLES := {
Spoke.OBSERVE: -PI / 2.0,
Spoke.INSERT: PI / 2.0,
}
const SPOKE_NAMES := {
Spoke.OBSERVE: "Observe",
Spoke.INSERT: "Insert",
}
var _open: bool = false
var _origin: Vector2 = Vector2.ZERO
var _hovered_spoke: int = Spoke.NONE
var _drag_mode: bool = false
var _insert_active: bool = false
func _ready() -> void:
visible = false
mouse_filter = Control.MOUSE_FILTER_STOP
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_RIGHT:
if event.pressed and not _open:
_open_menu(event.global_position)
get_viewport().set_input_as_handled()
elif not event.pressed and _open and _drag_mode:
# Drag-release: select hovered spoke
_confirm_selection()
get_viewport().set_input_as_handled()
elif event.pressed and _open and not _drag_mode:
# Click-click: second click confirms
_confirm_selection()
get_viewport().set_input_as_handled()
if event is InputEventMouseMotion and _open:
_update_hover(event.global_position)
queue_redraw()
func _open_menu(pos: Vector2) -> void:
_open = true
_origin = pos
_hovered_spoke = Spoke.NONE
_drag_mode = true
visible = true
# Position the control so _origin is at center
global_position = _origin - size / 2.0
queue_redraw()
func _close_menu() -> void:
_open = false
_hovered_spoke = Spoke.NONE
_drag_mode = false
visible = false
func _update_hover(mouse_pos: Vector2) -> void:
var delta := mouse_pos - _origin
var dist := delta.length()
if dist < INNER_RADIUS:
_hovered_spoke = Spoke.NONE
_drag_mode = dist > 4.0 # Still dragging if moved at all
return
_drag_mode = true
var angle := delta.angle()
# Find closest spoke
var best_spoke: int = Spoke.NONE
var best_diff: float = PI # Max angular distance
for spoke in SPOKE_ANGLES:
var spoke_angle: float = SPOKE_ANGLES[spoke]
var diff := absf(angle_difference(angle, spoke_angle))
if diff < best_diff and diff < PI / 3.0: # 60° acceptance zone
best_diff = diff
best_spoke = spoke
_hovered_spoke = best_spoke
func _confirm_selection() -> void:
if _hovered_spoke != Spoke.NONE:
var name: String = SPOKE_NAMES.get(_hovered_spoke, "")
spoke_selected.emit(name)
if _hovered_spoke == Spoke.INSERT:
_activate_insert()
_close_menu()
func _activate_insert() -> void:
# Send Pause to freeze game while viewing insert data
if not _insert_active:
_insert_active = true
_send_pause()
func _send_pause() -> void:
SimBridge.send_input({
"action": InputMapper.Action.PAUSE,
"timestamp_msec": Time.get_ticks_msec(),
})
func deactivate_insert() -> void:
# Called when closing insert view — send Pause again (toggle)
if _insert_active:
_insert_active = false
_send_pause()
func _draw() -> void:
if not _open:
return
var center := size / 2.0
# Background circle
draw_circle(center, SPOKE_RADIUS + 8.0, BG_COLOR)
# Inner ring
draw_arc(center, INNER_RADIUS, 0, TAU, 32, LINE_DIM, 1.0)
# Outer ring
draw_arc(center, SPOKE_RADIUS, 0, TAU, 48, LINE_DIM, 1.0)
# Spokes
for spoke in SPOKE_ANGLES:
var angle: float = SPOKE_ANGLES[spoke]
var dir := Vector2(cos(angle), sin(angle))
var inner_pt := center + dir * INNER_RADIUS
var outer_pt := center + dir * SPOKE_RADIUS
var color := HOVER_COLOR if spoke == _hovered_spoke else LINE_COLOR
var width := LINE_WIDTH * 2.0 if spoke == _hovered_spoke else LINE_WIDTH
# Spoke line
draw_line(inner_pt, outer_pt, color, width)
# Icon at spoke tip
var icon_center := center + dir * (SPOKE_RADIUS + ICON_SIZE + 4.0)
_draw_spoke_icon(spoke, icon_center, color)
# Label
var label: String = SPOKE_NAMES.get(spoke, "")
var font := ThemeDB.fallback_font
var text_size := font.get_string_size(label, HORIZONTAL_ALIGNMENT_CENTER, -1, 11)
var label_pos := icon_center + Vector2(-text_size.x / 2.0, ICON_SIZE + 14.0)
draw_string(font, label_pos, label, HORIZONTAL_ALIGNMENT_LEFT, -1, 11, color)
func _draw_spoke_icon(spoke: int, center: Vector2, color: Color) -> void:
match spoke:
Spoke.OBSERVE:
# Eye icon — simple geometric eye shape
var hw := ICON_SIZE * 0.7
var hh := ICON_SIZE * 0.4
# Eye outline (two arcs)
draw_arc(center - Vector2(0, hh * 0.3), hw, PI * 0.15, PI * 0.85, 12, color, 1.0)
draw_arc(center + Vector2(0, hh * 0.3), hw, -PI * 0.85, -PI * 0.15, 12, color, 1.0)
# Pupil
draw_circle(center, 2.5, color)
Spoke.INSERT:
# Phone/device icon — simple rectangle
var hw := ICON_SIZE * 0.35
var hh := ICON_SIZE * 0.55
draw_rect(Rect2(center - Vector2(hw, hh), Vector2(hw * 2, hh * 2)), color, false, 1.0)
# Screen line
draw_line(center - Vector2(hw * 0.6, hh * 0.3), center + Vector2(hw * 0.6, -hh * 0.3), color, 1.0)
# -- Public API ---------------------------------------------------------------
func is_open() -> bool:
return _open
func get_hovered_spoke() -> String:
return SPOKE_NAMES.get(_hovered_spoke, "")
func is_insert_active() -> bool:
return _insert_active
+14
View File
@@ -0,0 +1,14 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://ui/world_radial.gd" id="1_radial"]
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" type="Control"]
layout_mode = 3
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
mouse_filter = 2
script = ExtResource("1_radial")