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
+11
View File
@@ -21,6 +21,7 @@ SimBridge="*res://scripts/autoloads/sim_bridge.gd"
GameState="*res://scripts/autoloads/game_state.gd"
InputMapper="*res://scripts/autoloads/input_mapper.gd"
UIStrings="*res://scripts/autoloads/ui_strings.gd"
FogState="*res://scripts/autoloads/fog_state.gd"
[display]
@@ -94,6 +95,16 @@ pause={
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":32,"key_label":0,"unicode":32,"location":0,"echo":false,"script":null)
]
}
stance_up={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":67,"key_label":0,"unicode":99,"location":0,"echo":false,"script":null)
]
}
stance_down={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":88,"key_label":0,"unicode":120,"location":0,"echo":false,"script":null)
]
}
[rendering]
+99 -7
View File
@@ -1,36 +1,114 @@
[gd_scene load_steps=10 format=3 uid="uid://bswrmh7w8dbgm"]
[gd_scene load_steps=15 format=3 uid="uid://bswrmh7w8dbgm"]
[ext_resource type="Script" path="res://scripts/main.gd" id="1_main"]
[ext_resource type="Script" path="res://scripts/rendering/world_renderer.gd" id="2_world"]
[ext_resource type="Script" path="res://scripts/rendering/entity_renderer.gd" id="3_entity"]
[ext_resource type="Script" path="res://scripts/rendering/fog_renderer.gd" id="4_fog"]
[ext_resource type="Script" path="res://scripts/rendering/fog_shader.gd" id="4_fog"]
[ext_resource type="Script" path="res://scripts/rendering/tile_renderer.gd" id="5_tile"]
[ext_resource type="PackedScene" path="res://ui/hud.tscn" id="6_hud"]
[ext_resource type="PackedScene" path="res://ui/minimap.tscn" id="7_minimap"]
[ext_resource type="PackedScene" path="res://ui/monologue_display.tscn" id="8_monologue"]
[ext_resource type="PackedScene" path="res://ui/interaction_prompt.tscn" id="9_prompt"]
[ext_resource type="Script" path="res://scripts/rendering/cursor_renderer.gd" id="10_cursor"]
[ext_resource type="PackedScene" path="res://ui/interaction_list.tscn" id="11_ilist"]
[ext_resource type="PackedScene" path="res://ui/inventory_grid.tscn" id="12_inv"]
[ext_resource type="PackedScene" path="res://ui/stance_indicator.tscn" id="13_stance"]
[ext_resource type="PackedScene" path="res://ui/world_radial.tscn" id="14_radial"]
[node name="Game" type="Node2D"]
script = ExtResource("1_main")
; D-049 Z-level rendering pipeline (z-layer-gap-analysis.md)
;
; World scope (z:-200 to z:900, inside FogGroup CanvasGroup):
; z:0 FloorTiles — ground plane
; z:10 FloorObjects — cosmetic floor detail, ground shadows
; z:100 YSortGroup — y-sorted: furniture, entities, wall faces (all z:0 relative)
; z:200 [future] — airborne (projectiles, low-flying objects)
; z:300 Overhead — ceiling edges, semi-transparent occlusion
; z:350 [future] — high airborne (above ceiling, scale > 1.0)
; z:400 [future] — upper floor content (rare with fixed camera)
; z:900 FogOverlay — OUTSIDE FogGroup, fog shader
;
; Y-sort contract: ALL children of YSortGroup that participate in positional
; occlusion MUST have z_index = 0. z_index is PRIMARY sort, y is SECONDARY.
; Entities node is AFTER Furniture node — D-044 entity-wins-ties on same y.
[node name="World" type="Node2D" parent="."]
script = ExtResource("2_world")
[node name="TileMapLayer" type="TileMapLayer" parent="World"]
; --- World content inside CanvasGroup for fog compositing ---
; CanvasGroup captures everything beneath it into one texture.
; FogOverlay (outside) draws the fog shader over this composited texture.
[node name="FogGroup" type="CanvasGroup" parent="World"]
; z:0 — Floor tiles: zone identity, movement surface
[node name="FloorTiles" type="TileMapLayer" parent="World/FogGroup"]
z_index = 0
script = ExtResource("5_tile")
[node name="FogOverlay" type="TileMapLayer" parent="World"]
script = ExtResource("4_fog")
; z:10 — Floor objects: cosmetic detail, walked over, ground shadows from airborne
; (placeholder — populated when floor object art is added)
[node name="FloorObjects" type="Node2D" parent="World/FogGroup"]
z_index = 10
[node name="Entities" type="Node2D" parent="World"]
; z:100 — Y-sorted group: furniture + entities + wall faces interleave by y-position
; ALL children MUST use z_index = 0 (y-sort contract, Godot #62715)
[node name="YSortGroup" type="Node2D" parent="World/FogGroup"]
y_sort_enabled = true
z_index = 100
; Furniture (z:0 relative) — tables, chairs, placed objects
; Placeholder — before Entities in tree order so entities win visual ties (D-044)
[node name="Furniture" type="Node2D" parent="World/FogGroup/YSortGroup"]
y_sort_enabled = true
z_index = 0
; Entities (z:0 relative) — D-033 colored sprites, y-sorted with furniture
; MUST be z_index = 0 for correct y-sort interleaving
[node name="Entities" type="Node2D" parent="World/FogGroup/YSortGroup"]
y_sort_enabled = true
z_index = 0
script = ExtResource("3_entity")
; z:300 — Overhead: ceiling edges, upper floor structure, semi-transparent occlusion
; (placeholder — populated when overhead art is added)
[node name="Overhead" type="Node2D" parent="World/FogGroup"]
z_index = 300
; --- z:900 — Fog of perception (D-059 shader-based) ---
; OUTSIDE FogGroup. ColorRect child with fragment shader composites
; 5-layer fog over the world. fog_shader.gd manages uniforms.
[node name="FogOverlay" type="Node2D" parent="World"]
z_index = 900
script = ExtResource("4_fog")
; --- Camera ---
[node name="Camera2D" type="Camera2D" parent="."]
position_smoothing_enabled = true
position_smoothing_speed = 6.0
zoom = Vector2(2, 2)
; --- Insert overlay (CanvasLayer 10) ---
; Bloom-rendered, NOT affected by fog or camera transform.
; World-anchored elements convert world→screen coords in scripts.
[node name="InsertOverlay" type="CanvasLayer" parent="."]
layer = 10
; InteractionPrompt — v0.1 fallback single-line "E - Talk" display
[node name="InteractionPrompt" parent="InsertOverlay" instance=ExtResource("9_prompt")]
; D-057: Entity interaction vertical list — multi-verb, insert-styled
[node name="InteractionList" parent="InsertOverlay" instance=ExtResource("11_ilist")]
; D-058: World radial menu — right-click, 2 spokes (Observe + Insert)
[node name="WorldRadial" parent="InsertOverlay" instance=ExtResource("14_radial")]
; --- UI layer (CanvasLayer 20) ---
; HUD, monologue, cursor — always visible, not affected by fog or camera.
[node name="UILayer" type="CanvasLayer" parent="."]
layer = 20
[node name="HUD" parent="UILayer" instance=ExtResource("6_hud")]
@@ -38,4 +116,18 @@ zoom = Vector2(2, 2)
[node name="MonologueDisplay" parent="UILayer" instance=ExtResource("8_monologue")]
[node name="InteractionPrompt" parent="UILayer" instance=ExtResource("9_prompt")]
; D-053: Stance indicator — top-right, color-coded
[node name="StanceIndicator" parent="UILayer" instance=ExtResource("13_stance")]
; D-065: Inventory grid — 3x3, bottom-right, 40x40px, 1-9 hotkeys
[node name="InventoryGrid" parent="UILayer" instance=ExtResource("12_inv")]
; D-056: Cursor state machine — insert-styled geometric cursor, topmost in UILayer
[node name="CursorRenderer" type="Node2D" parent="UILayer"]
script = ExtResource("10_cursor")
; --- Modal layer (CanvasLayer 30) ---
; Full-screen overlays: pause menu, inventory modal, death screen.
; Empty for Sprint 6 — exists so the layer is reserved in the tree.
[node name="ModalLayer" type="CanvasLayer" parent="."]
layer = 30
+112
View File
@@ -0,0 +1,112 @@
extends Node
## Fog texture state — visibility/exploration/zone tint images updated from GameState.
## Read by fog_shader.gd for shader uniforms. Not a renderer — pure data.
## Architecture: docs/architecture/fog-shader-spec.md | D-059
var map_bounds: Rect2i = Rect2i(0, 0, 1, 1)
var visibility_texture: ImageTexture
var exploration_texture: ImageTexture
var zone_tint_texture: ImageTexture
var _vis_bytes: PackedByteArray
var _exp_bytes: PackedByteArray
var _vis_image: Image
var _exp_image: Image
var _tint_image: Image
var _width: int = 1
var _height: int = 1
var _prev_visible: Dictionary = {} # Tiles visible last frame (for incremental decay)
func _ready() -> void:
_resize(Rect2i(0, 0, 64, 64))
func _resize(bounds: Rect2i) -> void:
map_bounds = bounds
_width = maxi(bounds.size.x, 1)
_height = maxi(bounds.size.y, 1)
var sz := _width * _height
_vis_bytes = PackedByteArray()
_vis_bytes.resize(sz)
_vis_bytes.fill(0)
_vis_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture = ImageTexture.create_from_image(_vis_image)
_exp_bytes = PackedByteArray()
_exp_bytes.resize(sz)
_exp_bytes.fill(0)
_exp_image = Image.create_from_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture = ImageTexture.create_from_image(_exp_image)
# Zone tint — neutral dark for Sprint 6 (zone metadata deferred)
_tint_image = Image.create(_width, _height, false, Image.FORMAT_RGB8)
_tint_image.fill(Color(0.05, 0.05, 0.08))
zone_tint_texture = ImageTexture.create_from_image(_tint_image)
_prev_visible.clear()
func update_from_state() -> void:
# Resize if map bounds changed
var tiles := GameState.visible_tiles
if tiles.size() > 0:
var new_bounds := _compute_bounds(tiles)
if new_bounds != map_bounds:
_resize(new_bounds)
var ox: int = map_bounds.position.x
var oy: int = map_bounds.position.y
var positions: Dictionary = GameState.visible_positions
var sectors: Dictionary = GameState.visibility_sectors
# 1. Clear visibility, then write current LOS
_vis_bytes.fill(0)
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px < 0 or py < 0 or px >= _width or py >= _height:
continue
var sector: String = sectors.get(pos, "Forward")
_vis_bytes[py * _width + px] = 255 if sector == "Forward" else 180
_vis_image.set_data(_width, _height, false, Image.FORMAT_R8, _vis_bytes)
visibility_texture.update(_vis_image)
# 2. Exploration: tiles leaving LOS decay to 128, visible tiles stay 255
# Only touch tiles that changed (O(visible) not O(map_size))
for pos in _prev_visible:
if not positions.has(pos):
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
var idx: int = py * _width + px
if _exp_bytes[idx] > 128:
_exp_bytes[idx] = 128
for pos in positions:
var px: int = pos.x - ox
var py: int = pos.y - oy
if px >= 0 and py >= 0 and px < _width and py < _height:
_exp_bytes[py * _width + px] = 255
_exp_image.set_data(_width, _height, false, Image.FORMAT_R8, _exp_bytes)
exploration_texture.update(_exp_image)
_prev_visible = positions.duplicate()
func _compute_bounds(tiles: Array) -> Rect2i:
var min_x := 999999
var min_y := 999999
var max_x := -999999
var max_y := -999999
for tile in tiles:
if not tile is Dictionary or not tile.has("x") or not tile.has("y"):
continue
min_x = mini(min_x, int(tile.x))
min_y = mini(min_y, int(tile.y))
max_x = maxi(max_x, int(tile.x))
max_y = maxi(max_y, int(tile.y))
# Margin for fog gradient bleed at edges
return Rect2i(min_x - 4, min_y - 4, max_x - min_x + 9, max_y - min_y + 9)
+42
View File
@@ -5,6 +5,48 @@ class_name Constants
## Tile size in pixels — all renderers and coordinate conversions use this.
const TILE_SIZE: int = 32
# D-049 Z-level rendering pipeline — three-scope architecture.
# Full spec: docs/architecture/z-layer-gap-analysis.md
#
# WORLD SCOPE (z:-200 to z:900, inside FogGroup CanvasGroup)
# All world content composited into one texture, then fog drawn over it.
# Y-sort contract: all children of YSortGroup MUST use z_index = 0.
# z_index is PRIMARY sort, y-position is SECONDARY (Godot #62715).
const Z_FLOOR: int = 0 # Floor tiles — ground plane
const Z_FLOOR_OBJECTS: int = 10 # Floor objects — cosmetic, ground shadows
# z:20-99 reserved: liquid surface (z:110), surface effects
const Z_YSORT: int = 100 # YSortGroup — furniture + entities + walls, all z:0 relative
# z:110 reserved: liquid surface occlusion (alpha by depth)
# z:150-199 reserved: ground VFX (smoke origins, gas pools, sparks)
const Z_AIRBORNE: int = 200 # Projectiles, low-flying objects (scale ~1.0)
# z:250-299 reserved: mid-air VFX (rising smoke, floating particles)
const Z_OVERHEAD: int = 300 # Ceiling edges, upper structure, semi-transparent
# z:325-349 reserved: ceiling VFX (smoke through ceiling)
const Z_HIGH_AIRBORNE: int = 350 # Above-ceiling flying (scale 1.03-1.30, alpha fades)
const Z_UPPER_CONTENT: int = 400 # Upper-floor entities (rare with fixed camera)
# z:500-899 reserved: edge cases
const Z_FOG: int = 900 # FogOverlay — OUTSIDE FogGroup, fog shader
# Lower floors: z:-100 per floor (floor-1: z:-100 to z:-1, floor-2: z:-200 to z:-101)
# z:-75 to z:-51 reserved: lower floor VFX
#
# INSERT SCOPE (CanvasLayer 10)
# Bloom-rendered, not affected by fog or camera transform.
const CANVAS_INSERT: int = 10 # CanvasLayer number for InsertOverlay
#
# UI SCOPE (CanvasLayer 20)
# HUD, monologue, cursor — always visible.
const CANVAS_UI: int = 20 # CanvasLayer number for UILayer
#
# MODAL SCOPE (CanvasLayer 30)
# Full-screen overlays: pause, inventory modal, death screen.
const CANVAS_MODAL: int = 30 # CanvasLayer number for ModalLayer
#
# Rendering ceiling: 10 floors (25m) above current floor.
# Above this: no sprites, ground shadows + environmental effects only.
const RENDER_CEILING_FLOORS: int = 10
# Visible floor window (looking down): current - 2 floors (detailed + parallax).
const VISIBLE_FLOOR_DEPTH: int = 2
# D-033: Entity relationship color palette
# Color represents the player's RELATIONSHIP to the entity, not an objective property.
# Phase 1: default colors mapped by entity kind (Player/Npc/Object/Terrain).
+28 -3
View File
@@ -4,7 +4,12 @@ extends Node2D
@onready var camera = $Camera2D
@onready var hud = $UILayer/HUD
@onready var monologue_display = $UILayer/MonologueDisplay
@onready var interaction_prompt = $UILayer/InteractionPrompt
@onready var interaction_prompt = $InsertOverlay/InteractionPrompt # v0.1 single-line fallback
@onready var interaction_list = $InsertOverlay/InteractionList # D-057: z-layer 6
@onready var world_radial = $InsertOverlay/WorldRadial # D-058: z-layer 6
@onready var inventory_grid = $UILayer/InventoryGrid # D-065: z-layer 7
@onready var stance_indicator = $UILayer/StanceIndicator # D-053: z-layer 7
@onready var cursor_renderer = $UILayer/CursorRenderer # D-056: z-layer 7
func _ready() -> void:
print("The Settled Reach — client initialized")
@@ -22,6 +27,18 @@ func _process(_delta: float) -> void:
if world_renderer and world_renderer.has_method("update_from_state"):
world_renderer.update_from_state()
# D-057: Update interaction list from game state
if interaction_list and interaction_list.has_method("update_from_state"):
interaction_list.update_from_state()
# D-065: Update inventory grid
if inventory_grid and inventory_grid.has_method("update_from_state"):
inventory_grid.update_from_state()
# D-053: Update stance indicator
if stance_indicator and stance_indicator.has_method("update_from_state"):
stance_indicator.update_from_state()
# Show monologue if server sent one this tick (#414)
if GameState.current_monologue != null and monologue_display:
var mono: Dictionary = GameState.current_monologue
@@ -36,12 +53,20 @@ func _process(_delta: float) -> void:
var inputs = InputMapper.flush_queue()
for input in inputs:
if input.action == InputMapper.Action.INTERACT:
var target_id: int = interaction_prompt.get_interaction_target()
# D-057: prefer interaction list (multi-verb), fall back to prompt (v0.1)
var target_id: int = -1
var verb: String = ""
if interaction_list and interaction_list.has_method("get_interaction_target"):
target_id = interaction_list.get_interaction_target()
verb = interaction_list.get_selected_verb()
if target_id < 0 and interaction_prompt:
target_id = interaction_prompt.get_interaction_target()
verb = interaction_prompt.get_selected_verb()
# Always send struct form for Interact (#415) — server expects named fields
if target_id >= 0:
input["action_data"] = {
"target_entity_id": target_id,
"verb": interaction_prompt.get_selected_verb(),
"verb": verb,
}
else:
input["action_data"] = {
+307
View File
@@ -0,0 +1,307 @@
class_name CursorRenderer
extends Node2D
## Cursor state machine — 4 geometric states, 150ms linear transitions (D-056).
## Insert-styled cursor on z-layer 7 (UILayer CanvasLayer).
## Detects entity hover via world-space proximity to visible entities.
enum State { DEFAULT, ENTITY_HOVER, OBJECT_HOVER, WEAPON_AIM }
# --- Public ---
var current_state: State = State.DEFAULT
var hovered_entity_id: int = -1
var weapon_mode_active: bool = false
signal state_changed(new_state: State)
signal hovered_entity_changed(entity_id: int)
# D-056 colors
const COLOR_DEFAULT := Color("#c8d0e0")
const COLOR_OBJECT := Color("#8b8ba0")
const COLOR_OBJECT_FLAGGED := Color("#e8c547")
const COLOR_WEAPON := Color("#f0e8d8")
const TRANSITION_SEC := 0.15 # 150ms linear (D-056)
const HOVER_RADIUS_PX := 16.0 # World pixels — ~half a tile
# Bracket geometry (screen pixels — sized for 24px entity at 2x zoom)
const BRACKET_HALF := 26.0
const BRACKET_ARM := 8.0
# --- Transition state ---
var _target: State = State.DEFAULT
var _t: float = 1.0
var _from: Dictionary = {}
var _time: float = 0.0
var _mouse_inside: bool = true
var _shift_held: bool = false
# Hover tracking
var _hover_color: Color = COLOR_DEFAULT
var _hover_offset: Vector2 = Vector2.ZERO
# Interpolated draw params
var _gap: float = 4.0
var _len: float = 6.0
var _rot: float = 0.0
var _thick: float = 1.0
var _color: Color = COLOR_DEFAULT
var _bloom: float = 0.4
var _bracket_a: float = 0.0
var _alpha: float = 0.45
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_HIDDEN)
_from = _snapshot()
func _notification(what: int) -> void:
if what == NOTIFICATION_WM_MOUSE_EXIT:
_mouse_inside = false
visible = false
elif what == NOTIFICATION_WM_MOUSE_ENTER:
_mouse_inside = true
visible = true
func _process(delta: float) -> void:
if not _mouse_inside:
return
_time += delta
position = get_viewport().get_mouse_position()
_detect_hover()
if _t < 1.0:
_t = minf(_t + delta / TRANSITION_SEC, 1.0)
_interpolate()
if _t >= 1.0:
current_state = _target
queue_redraw()
# --- Hover detection (automatic, from GameState.visible_entities) ---
func _detect_hover() -> void:
var prev_id := hovered_entity_id
if weapon_mode_active:
var found := _find_nearest_entity()
hovered_entity_id = found.id
_hover_color = found.color
_hover_offset = found.offset
_set_target(State.WEAPON_AIM)
if hovered_entity_id != prev_id:
hovered_entity_changed.emit(hovered_entity_id)
return
var found := _find_nearest_entity()
hovered_entity_id = found.id
_hover_color = found.color
_hover_offset = found.offset
var new_state := State.DEFAULT
if found.id >= 0:
new_state = State.ENTITY_HOVER if found.kind == "Npc" else State.OBJECT_HOVER
_set_target(new_state)
if hovered_entity_id != prev_id:
hovered_entity_changed.emit(hovered_entity_id)
func _find_nearest_entity() -> Dictionary:
var xform := get_viewport().get_canvas_transform()
var mouse_screen := get_viewport().get_mouse_position()
var mouse_world: Vector2 = xform.affine_inverse() * mouse_screen
var best_dist := INF
var result := { id = -1, kind = "", color = COLOR_DEFAULT, offset = Vector2.ZERO }
for entity in GameState.visible_entities:
if not entity.has("entity_id") or not entity.has("x") or not entity.has("y"):
continue
if entity.entity_id == GameState.player_entity_id:
continue
var center := Vector2(
floorf(entity.x) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5,
floorf(entity.y) * Constants.TILE_SIZE + Constants.TILE_SIZE * 0.5
)
var dist := mouse_world.distance_to(center)
if dist < HOVER_RADIUS_PX and dist < best_dist:
best_dist = dist
result.id = entity.entity_id
result.kind = entity.get("kind", {}).get("variant", "")
result.color = EntityRenderer._color_for_kind(entity)
result.offset = (xform * center) - mouse_screen
return result
# --- State transitions ---
func _set_target(new_state: State) -> void:
if new_state == _target:
return
_target = new_state
_from = _snapshot()
# D-056: weapon aim is "hard transition" — skip interpolation
if new_state == State.WEAPON_AIM:
_t = 1.0
_apply_params(_params_for(State.WEAPON_AIM))
current_state = State.WEAPON_AIM
else:
_t = 0.0
state_changed.emit(new_state)
func _snapshot() -> Dictionary:
return { gap = _gap, len = _len, rot = _rot, thick = _thick,
color = _color, bloom = _bloom, bracket_a = _bracket_a, alpha = _alpha }
func _params_for(state: State) -> Dictionary:
match state:
State.ENTITY_HOVER:
return { gap = 10.0, len = 6.0, rot = 0.0, thick = 1.0,
color = _hover_color, bloom = 0.5, bracket_a = 1.0, alpha = 1.0 }
State.OBJECT_HOVER:
return { gap = 4.0, len = 6.0, rot = PI / 4.0, thick = 1.0,
color = _hover_color, bloom = 0.3, bracket_a = 0.0, alpha = 0.8 }
State.WEAPON_AIM:
return { gap = 12.0, len = 9.0, rot = 0.0, thick = 2.0,
color = COLOR_WEAPON, bloom = 0.0, bracket_a = 0.0, alpha = 1.0 }
_:
return { gap = 4.0, len = 6.0, rot = 0.0, thick = 1.0,
color = COLOR_DEFAULT, bloom = 0.4, bracket_a = 0.0, alpha = 0.45 }
func _apply_params(p: Dictionary) -> void:
_gap = p.gap; _len = p.len; _rot = p.rot; _thick = p.thick
_color = p.color; _bloom = p.bloom; _bracket_a = p.bracket_a; _alpha = p.alpha
func _interpolate() -> void:
var p := _params_for(_target)
_gap = lerpf(_from.gap, p.gap, _t)
_len = lerpf(_from.len, p.len, _t)
_rot = lerp_angle(_from.rot, p.rot, _t)
_thick = lerpf(_from.thick, p.thick, _t)
_color = _from.color.lerp(p.color, _t)
_bloom = lerpf(_from.bloom, p.bloom, _t)
_bracket_a = lerpf(_from.bracket_a, p.bracket_a, _t)
_alpha = lerpf(_from.alpha, p.alpha, _t)
# --- Drawing ---
func _draw() -> void:
# Bloom pass — wider, semi-transparent glow
if _bloom > 0.01:
var bloom_mod := 1.0
# D-056: ~10% bloom pulse during entity hover
if current_state == State.ENTITY_HOVER:
bloom_mod += sin(_time * 4.0) * 0.1
var bloom_a := _alpha * _bloom * 0.6 * bloom_mod
_draw_ticks(Color(_color, bloom_a), _thick + 3.0)
if _bracket_a > 0.01:
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _bloom * 0.4))
# Crisp pass
_draw_ticks(Color(_color, _alpha), _thick)
if _bracket_a > 0.01:
_draw_brackets(_hover_offset, Color(_color, _bracket_a * _alpha))
func _draw_ticks(color: Color, thickness: float) -> void:
var dirs := [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
for dir in dirs:
var d := dir.rotated(_rot)
draw_line(d * _gap, d * (_gap + _len), color, thickness, true)
func _draw_brackets(offset: Vector2, color: Color) -> void:
for sx in [-1.0, 1.0]:
for sy in [-1.0, 1.0]:
var corner := offset + Vector2(sx * BRACKET_HALF, sy * BRACKET_HALF)
draw_line(corner, corner + Vector2(-sx * BRACKET_ARM, 0), color, 1.0, true)
draw_line(corner, corner + Vector2(0, -sy * BRACKET_ARM), color, 1.0, true)
# --- Test-friendly API (expected by test_cursor_states.gd) ---
func get_state() -> String:
match current_state:
State.DEFAULT: return "Default"
State.ENTITY_HOVER: return "EntityHover"
State.OBJECT_HOVER: return "ObjectHover"
State.WEAPON_AIM: return "WeaponAim"
_: return "Default"
func set_hover_target(data: Dictionary) -> void:
var kind: String = data.get("kind", "")
# D-056: cursor changes require LOS
if not data.get("in_los", true):
return
hovered_entity_id = data.get("entity_id", -1)
if kind == "Npc":
var rel: String = data.get("relationship", "Unknown")
match rel:
"Friendly": _hover_color = Constants.ENTITY_COLOR_FRIENDLY
"PersonOfInterest": _hover_color = Constants.ENTITY_COLOR_POI
"Hostile": _hover_color = Constants.ENTITY_COLOR_HOSTILE
_: _hover_color = Constants.ENTITY_COLOR_UNKNOWN
_target = State.ENTITY_HOVER
_t = 1.0
_apply_params(_params_for(State.ENTITY_HOVER))
current_state = State.ENTITY_HOVER
elif kind == "Object" or kind == "Terrain":
_hover_color = COLOR_OBJECT_FLAGGED if data.get("flagged", false) else COLOR_OBJECT
_target = State.OBJECT_HOVER
_t = 1.0
_apply_params(_params_for(State.OBJECT_HOVER))
current_state = State.OBJECT_HOVER
func clear_hover_target() -> void:
hovered_entity_id = -1
_hover_color = COLOR_DEFAULT
_target = State.DEFAULT
_t = 1.0
_apply_params(_params_for(State.DEFAULT))
current_state = State.DEFAULT
func set_weapon_mode(active: bool) -> void:
weapon_mode_active = active
if active:
_target = State.WEAPON_AIM
_t = 1.0
_apply_params(_params_for(State.WEAPON_AIM))
current_state = State.WEAPON_AIM
else:
_target = State.DEFAULT
_t = 1.0
_apply_params(_params_for(State.DEFAULT))
current_state = State.DEFAULT
func set_shift_held(held: bool) -> void:
_shift_held = held
func get_transition_duration() -> float:
return TRANSITION_SEC
func get_cursor_color() -> Color:
return _color
func get_z_layer() -> int:
return Constants.CANVAS_UI # D-049/D-056
func should_show_interactions() -> bool:
return not weapon_mode_active or _shift_held
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles
-88
View File
@@ -1,88 +0,0 @@
class_name FogRenderer
extends TileMapLayer
# Fog renderer — draws fog overlay on non-visible tiles (D-011)
# Three visibility states per tile:
# visible = no fog tile (clear)
# fog-edge = semi-transparent dark overlay (adjacent to visible)
# hidden = opaque black overlay
#
# Atlas layout:
# (0,0) = full fog (opaque black)
# (1,0) = fog edge (semi-transparent)
#
# Note: fog-edge uses 8-directional neighbors for visual smoothness.
# Actual visibility boundaries come from the server's shadowcasting (D-011).
# Fog-returns-over-time (D-011 decay) is tracked in #113, not here.
const TILE_SIZE: int = Constants.TILE_SIZE
var _initialized: bool = false
var _all_tile_positions: Dictionary = {} # Vector2i -> true, all known map tiles
func _ready() -> void:
_setup_tileset()
_initialized = true
print("FogRenderer: Initialized")
func _setup_tileset() -> void:
var ts := TileSet.new()
ts.tile_size = Vector2i(TILE_SIZE, TILE_SIZE)
var source := TileSetAtlasSource.new()
var img := Image.create(TILE_SIZE * 2, TILE_SIZE, false, Image.FORMAT_RGBA8)
# Full fog (0,0) — opaque black
img.fill_rect(Rect2i(0, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 1.0))
# Fog edge (1,0) — semi-transparent dark
img.fill_rect(Rect2i(TILE_SIZE, 0, TILE_SIZE, TILE_SIZE), Color(0.02, 0.02, 0.05, 0.6))
var tex := ImageTexture.create_from_image(img)
source.texture = tex
source.texture_region_size = Vector2i(TILE_SIZE, TILE_SIZE)
source.create_tile(Vector2i(0, 0))
source.create_tile(Vector2i(1, 0))
ts.add_source(source)
tile_set = ts
# Register all known tile positions (called when tile data arrives)
func register_tile_positions(tiles: Array) -> void:
_all_tile_positions.clear()
for tile_data in tiles:
if tile_data.has("x") and tile_data.has("y"):
_all_tile_positions[Vector2i(tile_data.x, tile_data.y)] = true
# Update fog based on visible positions.
# player_pos reserved for future fog-decay tracking (#113).
func update_fog(visible_positions: Dictionary, _player_pos: Vector2) -> void:
if not _initialized:
return
clear()
if _all_tile_positions.is_empty() or visible_positions.is_empty():
return
# Build set of fog-edge positions (8-directional neighbors of visible tiles)
var fog_edge: Dictionary = {}
var neighbors := [
Vector2i(-1, 0), Vector2i(1, 0), Vector2i(0, -1), Vector2i(0, 1),
Vector2i(-1, -1), Vector2i(1, -1), Vector2i(-1, 1), Vector2i(1, 1),
]
for pos in visible_positions:
for offset in neighbors:
var neighbor_pos: Vector2i = pos + offset
if not visible_positions.has(neighbor_pos) and _all_tile_positions.has(neighbor_pos):
fog_edge[neighbor_pos] = true
# Place fog tiles on all known positions that aren't visible
for pos in _all_tile_positions:
if visible_positions.has(pos):
continue # Visible — no fog
elif fog_edge.has(pos):
set_cell(pos, 0, Vector2i(1, 0)) # Fog edge — semi-transparent
else:
set_cell(pos, 0, Vector2i(0, 0)) # Full fog — opaque
+60
View File
@@ -0,0 +1,60 @@
extends Node2D
## Fog overlay controller — manages ColorRect + shader uniforms for D-059 fog.
## Reads textures from FogState autoload, positions rect to cover viewport.
## Architecture: docs/architecture/fog-shader-spec.md
var _fog_rect: ColorRect
var _shader_mat: ShaderMaterial
const TILE_SIZE := float(Constants.TILE_SIZE)
func _ready() -> void:
# Create the fog overlay ColorRect
_fog_rect = ColorRect.new()
_fog_rect.name = "FogRect"
add_child(_fog_rect)
# Load shader and create material
var shader := load("res://shaders/fog.gdshader") as Shader
_shader_mat = ShaderMaterial.new()
_shader_mat.shader = shader
_fog_rect.material = _shader_mat
# Create seamless noise texture for fog animation
var noise := FastNoiseLite.new()
noise.noise_type = FastNoiseLite.TYPE_PERLIN
noise.frequency = 0.03
var noise_tex := NoiseTexture2D.new()
noise_tex.noise = noise
noise_tex.width = 256
noise_tex.height = 256
noise_tex.seamless = true
_shader_mat.set_shader_parameter("noise_tex", noise_tex)
_shader_mat.set_shader_parameter("tile_size", TILE_SIZE)
print("FogShader: Initialized (D-059 5-layer)")
func update_fog() -> void:
# 1. Update FogState textures from GameState
FogState.update_from_state()
# 2. Position ColorRect to cover the current viewport
var vp_size := get_viewport().get_visible_rect().size
var zoom := Vector2(2.0, 2.0) # Must match Camera2D zoom
var camera_pos := GameState.player_position * TILE_SIZE
var half_view := vp_size / (2.0 * zoom)
_fog_rect.position = camera_pos - half_view
_fog_rect.size = vp_size / zoom
# 3. Update shader uniforms
_shader_mat.set_shader_parameter("visibility_tex", FogState.visibility_texture)
_shader_mat.set_shader_parameter("exploration_tex", FogState.exploration_texture)
_shader_mat.set_shader_parameter("zone_tint_tex", FogState.zone_tint_texture)
_shader_mat.set_shader_parameter("rect_pos", _fog_rect.position)
_shader_mat.set_shader_parameter("rect_sz", _fog_rect.size)
_shader_mat.set_shader_parameter("map_offset", Vector2(FogState.map_bounds.position))
_shader_mat.set_shader_parameter("map_size", Vector2(FogState.map_bounds.size))
_shader_mat.set_shader_parameter("time", Time.get_ticks_msec() / 1000.0)
+15 -8
View File
@@ -2,16 +2,25 @@ extends Node2D
# World renderer — manages all visual representation from GameState
# Attached to the World node in main.tscn
# Render order (scene tree): TileMapLayer -> FogOverlay -> Entities
#
# D-049 Z-level rendering pipeline (z-layer-gap-analysis.md):
# FogGroup (CanvasGroup) composites world content:
# FloorTiles (TileMapLayer) — z:0 ground plane
# FloorObjects (Node2D) — z:10 cosmetic detail (placeholder)
# YSortGroup (Node2D, y_sort) — z:100 furniture + entities (all z:0 relative)
# Furniture (Node2D, y_sort) — z:0 placed objects (placeholder)
# Entities (Node2D, y_sort) — z:0 entity sprites (D-033 colors)
# Overhead (Node2D) — z:300 ceiling/upper structure (placeholder)
# FogOverlay (Node2D) — z:900 fog shader (OUTSIDE FogGroup)
@onready var tile_renderer = $TileMapLayer
@onready var tile_renderer = $FogGroup/FloorTiles
@onready var fog_renderer = $FogOverlay
@onready var entity_renderer = $Entities
@onready var entity_renderer = $FogGroup/YSortGroup/Entities
var _last_tick: int = -1
func _ready() -> void:
print("WorldRenderer: Initialized")
print("WorldRenderer: Initialized (D-049 z-stack)")
# Called each frame to update visuals from game state.
# Uses tick-based invalidation — re-renders all layers when a new snapshot arrives.
@@ -25,12 +34,10 @@ func update_from_state() -> void:
if tile_renderer and tile_renderer.has_method("update_tiles"):
if GameState.visible_tiles.size() > 0:
tile_renderer.update_tiles(GameState.visible_tiles)
if fog_renderer and fog_renderer.has_method("register_tile_positions"):
fog_renderer.register_tile_positions(GameState.visible_tiles)
# Update fog overlay from visibility data
# Update fog overlay — shader-based, reads from FogState autoload
if fog_renderer and fog_renderer.has_method("update_fog"):
fog_renderer.update_fog(GameState.visible_positions, GameState.player_position)
fog_renderer.update_fog()
# Update entity sprites
if entity_renderer and entity_renderer.has_method("update_entities"):
+74
View File
@@ -0,0 +1,74 @@
shader_type canvas_item;
// D-059: 5-layer fog shader. Composites over world content (layers 0-4).
// Layer 1: Clear (vision cone) — transparent, soft gradient edge
// Layer 2: Light fog (peripheral) — desaturated + dim + animated noise, 8-10s cycle
// Layer 3: Deep fog (explored) — near-monochrome + zone tint + breathing, 15-20s cycle
// Layer 4: Unexplored + maps — wireframe (Sprint 6: deferred, treated as Layer 5)
// Layer 5: Unexplored, no maps — solid near-black #12141a
uniform sampler2D visibility_tex : filter_linear, repeat_disable;
uniform sampler2D exploration_tex : filter_linear, repeat_disable;
uniform sampler2D zone_tint_tex : filter_nearest, repeat_disable;
uniform sampler2D noise_tex : filter_linear, repeat_enable;
uniform vec2 rect_pos; // World-space position of the ColorRect (pixels)
uniform vec2 rect_sz; // World-space size of the ColorRect (pixels)
uniform vec2 map_offset; // map_bounds.position (tiles)
uniform vec2 map_size; // map_bounds.size (tiles)
uniform float tile_size; // Pixels per sim tile
uniform float time; // Seconds since start
// D-059 fog layer colors
const vec3 UNEXPLORED_COLOR = vec3(0.071, 0.078, 0.102); // #12141a
const vec3 DARK_OVERLAY = vec3(0.02, 0.02, 0.05);
// D-059 thresholds (after bilinear filtering)
// Forward tiles = 1.0, Peripheral = 0.706 (180/255), not-visible = 0.0
const float CLEAR_THRESHOLD = 0.85; // Above this: fully clear
const float PERIPHERAL_LOW = 0.15; // Below this: transition to deep/unexplored
void fragment() {
// Map UV (0-1 across ColorRect) to world pixels, then to tile coordinates
vec2 world_px = rect_pos + UV * rect_sz;
vec2 tile = world_px / tile_size;
// Map tile coordinate to texture UV
vec2 tex_uv = (tile - map_offset) / map_size;
// Outside known map → unexplored
if (tex_uv.x < 0.0 || tex_uv.x > 1.0 || tex_uv.y < 0.0 || tex_uv.y > 1.0) {
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
return;
}
float vis = texture(visibility_tex, tex_uv).r;
float explored = texture(exploration_tex, tex_uv).r;
if (vis > PERIPHERAL_LOW) {
// In or near vision cone
if (vis > CLEAR_THRESHOLD) {
// Layer 1: Clear — soft edge gradient
float edge = smoothstep(CLEAR_THRESHOLD, 1.0, vis);
COLOR = vec4(0.0, 0.0, 0.0, 1.0 - edge);
} else {
// Layer 2: Light fog (peripheral + forward edge)
// D-059: animated Perlin noise, 8-10s cycle
float noise_val = texture(noise_tex, tile * 0.03 + vec2(time * 0.11, time * 0.07)).r;
float coverage = smoothstep(PERIPHERAL_LOW, CLEAR_THRESHOLD, vis);
// Blend from heavy fog (alpha ~0.55) to lighter fog near clear edge
float alpha = mix(0.55, 0.25, coverage) + noise_val * 0.1;
COLOR = vec4(DARK_OVERLAY, alpha);
}
} else if (explored > 0.3) {
// Layer 3: Deep fog (previously explored, no longer in LOS)
// D-059: near-monochrome, ~10% zone temperature tint, 15-20s breathing cycle
vec3 zone_tint = texture(zone_tint_tex, tex_uv).rgb;
float noise_val = texture(noise_tex, tile * 0.015 + vec2(time * 0.045, time * 0.03)).r;
vec3 tint_color = mix(vec3(0.04), zone_tint, 0.1);
float alpha = mix(0.78, 0.90, noise_val); // Fog breathes
COLOR = vec4(tint_color, alpha);
} else {
// Layer 5: Unexplored, no maps — information zero
COLOR = vec4(UNEXPLORED_COLOR, 1.0);
}
}
+319
View File
@@ -0,0 +1,319 @@
## D-056: Cursor state machine tests (#429).
## Tests 4-state cursor transitions, timing, color accuracy, z-layer,
## and interaction suppression behavior per D-056 spec.
##
## These tests validate the cursor implementation once #429 lands.
## Some tests use mock data; others require the cursor scene/script.
##
## Spec refs: D-056, D-033, D-045, D-049
class_name TestCursorStates
extends GdUnitTestSuite
# -- Test helpers --------------------------------------------------------------
## Create cursor node from scene. Returns null if scene doesn't exist yet.
## Tests that call this will be skipped (not failed) if cursor isn't implemented.
func _make_cursor() -> Node:
var scene_path := "res://ui/cursor_state_machine.tscn"
if not ResourceLoader.exists(scene_path):
# Try alternative paths
for alt in ["res://scenes/cursor.tscn", "res://ui/cursor.tscn"]:
if ResourceLoader.exists(alt):
scene_path = alt
break
if not ResourceLoader.exists(scene_path):
return null
var scene = load(scene_path)
var cursor = scene.instantiate()
add_child(cursor)
return cursor
func _make_cursor_or_skip() -> Node:
var cursor = _make_cursor()
if cursor == null:
# Skip test: cursor not yet implemented
push_warning("TestCursorStates: cursor scene not found — test skipped (awaiting #429 implementation)")
return cursor
# -- State enumeration (D-056: exactly 4 states) -----------------------------
func test_cursor_has_four_states() -> void:
# Verify the cursor system defines exactly 4 states per D-056
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("get_state")).is_true()
# Default state should be the starting state
var initial_state = cursor.get_state()
assert_that(initial_state).is_not_null()
cursor.queue_free()
func test_cursor_initial_state_is_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default state — four thin inward-pointing ticks
var state = cursor.get_state()
assert_that(str(state)).is_equal("Default")
cursor.queue_free()
# -- State transitions (D-056: all transitions 150ms linear) ------------------
func test_transition_default_to_entity_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# Simulate hovering over an NPC entity
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("EntityHover")
cursor.queue_free()
func test_transition_default_to_object_hover() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
assert_that(str(cursor.get_state())).is_equal("ObjectHover")
cursor.queue_free()
func test_transition_default_to_weapon_aim() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_weapon_mode"):
cursor.set_weapon_mode(true)
assert_that(str(cursor.get_state())).is_equal("WeaponAim")
cursor.queue_free()
func test_transition_entity_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
func test_transition_object_hover_back_to_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target") and cursor.has_method("clear_hover_target"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown"})
cursor.clear_hover_target()
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- Timing (D-056: all transitions 150ms linear) ----------------------------
func test_transition_duration_constant() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "All transitions 150ms linear"
if cursor.has_method("get_transition_duration"):
var duration = cursor.get_transition_duration()
assert_float(duration).is_equal_approx(0.15, 0.001)
cursor.queue_free()
# -- Color accuracy (D-056 + D-033) -------------------------------------------
func test_default_cursor_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Default = white-blue #c8d0e0
if cursor.has_method("get_cursor_color"):
var color = cursor.get_cursor_color()
var expected = Color("#c8d0e0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_default() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover = muted grey #8b8ba0
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": false})
var color = cursor.get_cursor_color()
var expected = Color("#8b8ba0")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_object_hover_color_flagged() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Object hover (flagged) = amber #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 3, "kind": "Object", "relationship": "Unknown", "flagged": true})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_weapon_aim_color() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Weapon aim = warm white #f0e8d8, NO bloom
if cursor.has_method("set_weapon_mode") and cursor.has_method("get_cursor_color"):
cursor.set_weapon_mode(true)
var color = cursor.get_cursor_color()
var expected = Color("#f0e8d8")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Entity hover: D-033 relationship colors ----------------------------------
func test_entity_hover_unknown_uses_teal() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Unknown/Neutral = #4a9ebb
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown"})
var color = cursor.get_cursor_color()
var expected = Color("#4a9ebb")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_friendly_uses_green() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Known/Friendly = #6bc9a6
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Friendly"})
var color = cursor.get_cursor_color()
var expected = Color("#6bc9a6")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_poi_uses_amber() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Person of Interest = #e8c547
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "PersonOfInterest"})
var color = cursor.get_cursor_color()
var expected = Color("#e8c547")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
func test_entity_hover_hostile_uses_red() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-033: Hostile/Dangerous = #d45d5d
if cursor.has_method("set_hover_target") and cursor.has_method("get_cursor_color"):
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Hostile"})
var color = cursor.get_cursor_color()
var expected = Color("#d45d5d")
assert_that(color.is_equal_approx(expected)).is_true()
cursor.queue_free()
# -- Z-layer (D-049: cursor on layer 7) --------------------------------------
func test_cursor_z_layer() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: cursor on UI layer (CanvasLayer 20)
if cursor.has_method("get_z_layer"):
assert_that(cursor.get_z_layer()).is_equal(Constants.CANVAS_UI)
cursor.queue_free()
# -- Weapon mode suppression (D-056) -----------------------------------------
func test_weapon_mode_suppresses_interactions() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: "Weapon-selected mode suppresses interaction prompts unless Shift held"
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
assert_that(cursor.should_show_interactions()).is_false()
cursor.queue_free()
func test_weapon_mode_shift_override() -> void:
var cursor = _make_cursor_or_skip()
if cursor == null:
return
# D-056: Shift held in weapon mode restores interaction prompts
if cursor.has_method("set_weapon_mode") and cursor.has_method("should_show_interactions"):
cursor.set_weapon_mode(true)
if cursor.has_method("set_shift_held"):
cursor.set_shift_held(true)
assert_that(cursor.should_show_interactions()).is_true()
cursor.queue_free()
# -- Zone/narrative state invariance (D-045) ----------------------------------
func test_cursor_never_changes_by_zone() -> void:
# D-056: "Never changes by zone/narrative state (D-045)"
# This is a design constraint, not a unit test — but we verify the cursor
# doesn't accept zone/narrative state parameters.
var cursor = _make_cursor_or_skip()
if cursor == null:
return
assert_that(cursor.has_method("set_zone")).is_false()
assert_that(cursor.has_method("set_narrative_state")).is_false()
cursor.queue_free()
# -- LOS-gated cursor changes (D-056) ----------------------------------------
func test_cursor_changes_require_los() -> void:
# D-056: "Cursor changes on LOS, not just proximity"
# Entity hover should only activate if the entity is in LOS
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("set_hover_target"):
# Entity NOT in LOS → cursor should stay Default
cursor.set_hover_target({"entity_id": 2, "kind": "Npc", "relationship": "Unknown", "in_los": false})
# Should not transition to EntityHover if entity is not in LOS
assert_that(str(cursor.get_state())).is_equal("Default")
cursor.queue_free()
# -- Interaction range (D-056) ------------------------------------------------
func test_click_interaction_range() -> void:
# D-056: "Click interaction range: ~2 sim tiles"
var cursor = _make_cursor_or_skip()
if cursor == null:
return
if cursor.has_method("get_interaction_range"):
var range_val = cursor.get_interaction_range()
# ~2 sim tiles = 1m per D-066
assert_that(range_val).is_equal(2)
cursor.queue_free()
+297
View File
@@ -0,0 +1,297 @@
## D-059: Fog shader rebuild tests (#430).
## Validates FogState data management, fog shader integration,
## performance budget, and regression against old fog_renderer.gd API.
##
## Architecture reference: docs/architecture/fog-shader-spec.md
## Spec refs: D-059, D-049, D-033, D-060, D-066
class_name TestFogShader
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _fog_state_exists() -> bool:
# FogState should be an autoload once #430 lands
return Engine.has_singleton("FogState") or get_node_or_null("/root/FogState") != null
func _get_fog_state() -> Node:
var node = get_node_or_null("/root/FogState")
if node == null:
push_warning("TestFogShader: FogState autoload not found — test skipped (awaiting #430)")
return node
func _fog_shader_script_exists() -> bool:
return ResourceLoader.exists("res://scripts/rendering/fog_shader.gd")
func _fog_gdshader_exists() -> bool:
return ResourceLoader.exists("res://shaders/fog.gdshader")
# -- FogState autoload existence -----------------------------------------------
func test_fog_state_autoload_registered() -> void:
# fog-shader-spec.md: "New autoload: client/scripts/autoloads/fog_state.gd"
if not _fog_state_exists():
push_warning("TestFogShader: FogState autoload not registered — test skipped")
return
var fog_state = _get_fog_state()
assert_that(fog_state).is_not_null()
# -- FogState texture management -----------------------------------------------
func test_fog_state_has_visibility_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("visibility_texture") != null).is_true()
func test_fog_state_has_exploration_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("exploration_texture") != null).is_true()
func test_fog_state_has_zone_tint_texture() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("zone_tint_texture") != null).is_true()
func test_fog_state_has_map_bounds() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
assert_that(fog_state.get("map_bounds") != null).is_true()
# -- FogState update from GameState -------------------------------------------
func test_fog_state_update_from_visible_positions() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
# Set up GameState with visible positions
GameState.visible_positions = {Vector2i(5, 5): true, Vector2i(6, 5): true}
GameState.visibility_sectors = {Vector2i(5, 5): "Forward", Vector2i(6, 5): "Peripheral"}
if fog_state.has_method("update_from_state"):
fog_state.update_from_state()
# Visibility texture should be updated (non-null)
assert_that(fog_state.visibility_texture).is_not_null()
# Reset
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_fog_state_exploration_persists() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Tick 1: see tile (5,5)
GameState.visible_positions = {Vector2i(5, 5): true}
fog_state.update_from_state()
# Tick 2: no longer see tile (5,5) but it should remain explored
GameState.visible_positions.clear()
fog_state.update_from_state()
assert_that(fog_state.exploration_texture).is_not_null()
# The exploration state for (5,5) should be non-zero (explored, deep fog)
# Exact value depends on implementation — test that it's not unexplored
GameState.visible_positions.clear()
func test_fog_state_forward_vs_peripheral() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Forward tiles should have higher visibility value than Peripheral
GameState.visible_positions = {
Vector2i(5, 5): true,
Vector2i(6, 5): true,
}
GameState.visibility_sectors = {
Vector2i(5, 5): "Forward",
Vector2i(6, 5): "Peripheral",
}
fog_state.update_from_state()
# Per spec: Forward = 255, Peripheral = 180
assert_that(fog_state.visibility_texture).is_not_null()
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Fog shader script existence -----------------------------------------------
func test_fog_shader_script_exists() -> void:
if not _fog_shader_script_exists():
push_warning("TestFogShader: fog_shader.gd not found — test skipped")
return
assert_that(_fog_shader_script_exists()).is_true()
func test_fog_gdshader_file_exists() -> void:
if not _fog_gdshader_exists():
push_warning("TestFogShader: fog.gdshader not found — test skipped")
return
assert_that(_fog_gdshader_exists()).is_true()
# -- Old fog_renderer.gd should be deleted ------------------------------------
func test_old_fog_renderer_deleted() -> void:
# fog-shader-spec.md: "Delete fog_renderer.gd"
# This test will PASS once the old file is removed, FAIL if it still exists
# alongside the new fog shader.
if not _fog_shader_script_exists():
# New fog shader hasn't landed yet — skip this check
return
var old_exists = ResourceLoader.exists("res://scripts/rendering/fog_renderer.gd")
assert_that(old_exists).is_false()
# -- Performance: texture update budget (<1ms/frame total) --------------------
func test_visibility_texture_update_performance() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# Simulate a realistic tile count (~400 visible tiles)
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
var pos := Vector2i(x, y)
positions[pos] = true
sectors[pos] = "Forward" if y < 10 else "Peripheral"
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
# Measure update time
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# D-059: Visibility texture upload budget: 0.1ms
# Allow 2x margin for test environment overhead
assert_that(elapsed_ms).is_less(0.5)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
func test_full_fog_update_under_1ms() -> void:
var fog_state = _get_fog_state()
if fog_state == null:
return
if not fog_state.has_method("update_from_state"):
return
# D-059: <1ms/frame total for fog system (CPU side)
var positions := {}
var sectors := {}
for x in range(20):
for y in range(20):
positions[Vector2i(x, y)] = true
sectors[Vector2i(x, y)] = "Forward"
GameState.visible_positions = positions
GameState.visibility_sectors = sectors
var start := Time.get_ticks_usec()
fog_state.update_from_state()
var elapsed_us := Time.get_ticks_usec() - start
var elapsed_ms := elapsed_us / 1000.0
# Allow 2x margin: spec says <1ms, we allow <2ms for test overhead
assert_that(elapsed_ms).is_less(2.0)
GameState.visible_positions.clear()
GameState.visibility_sectors.clear()
# -- Shader uniform constants (D-059) -----------------------------------------
func test_fog_layer_color_constants() -> void:
# D-059 fog layer colors — verify constants are defined correctly
# Layer 5: Unexplored no maps = #12141a
var unexplored := Color("#12141a")
assert_float(unexplored.r).is_equal_approx(0.071, 0.01)
assert_float(unexplored.g).is_equal_approx(0.078, 0.01)
assert_float(unexplored.b).is_equal_approx(0.102, 0.01)
# Layer 4: Wireframe = #333340
var wireframe := Color("#333340")
assert_float(wireframe.r).is_equal_approx(0.2, 0.01)
assert_float(wireframe.g).is_equal_approx(0.2, 0.01)
assert_float(wireframe.b).is_equal_approx(0.251, 0.01)
# -- Regression: GameState visible_positions still works ----------------------
func test_game_state_visible_positions_unchanged() -> void:
# Ensure the fog shader doesn't break the visible_positions Dictionary
# that fog_renderer.gd used to consume
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [
{"x": 5, "y": 5, "z": 0, "visibility": "Forward"},
{"x": 6, "y": 5, "z": 0, "visibility": "Peripheral"},
],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_true()
assert_that(GameState.visible_positions.has(Vector2i(6, 5))).is_true()
assert_that(GameState.visibility_sectors[Vector2i(5, 5)]).is_equal("Forward")
assert_that(GameState.visibility_sectors[Vector2i(6, 5)]).is_equal("Peripheral")
func test_game_state_visible_positions_cleared_on_new_snapshot() -> void:
GameState.apply_snapshot({
"tick": 1,
"visible_tiles": [{"x": 5, "y": 5, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.visible_positions.size()).is_equal(1)
GameState.apply_snapshot({
"tick": 2,
"visible_tiles": [{"x": 10, "y": 10, "z": 0, "visibility": "Forward"}],
})
assert_that(GameState.visible_positions.has(Vector2i(5, 5))).is_false()
assert_that(GameState.visible_positions.has(Vector2i(10, 10))).is_true()
# -- Z-layer compliance (D-049) -----------------------------------------------
func test_fog_overlay_z_layer() -> void:
# fog-shader-spec.md: FogOverlay at z:10 (above world content)
# Per D-049, fog entities render on Layer 5
# The FogOverlay itself renders ABOVE the FogGroup (CanvasGroup)
if not _fog_shader_script_exists():
push_warning("TestFogShader: fog_shader.gd not found — z-layer test skipped")
return
# This test needs the scene tree to be set up
# Verify via scene file inspection rather than runtime
pass
# -- Noise animation cycles (D-059) -------------------------------------------
func test_light_fog_noise_cycle() -> void:
# D-059: Light fog animated Perlin noise, 8-10s cycle
# This is a shader constant — verify documentation, not runtime
# The shader should use TIME with a period of 8-10s
pass # Manual verification required — shader inspection
func test_deep_fog_noise_cycle() -> void:
# D-059: Deep fog more pronounced noise, 15-20s cycle
# Shader constant — manual verification
pass # Manual verification required — shader inspection
+351
View File
@@ -0,0 +1,351 @@
## D-057: Entity interaction vertical list tests (#432).
## Tests multi-verb vertical list rendering, insert styling, z-layer 6,
## verb click dispatch, and D-055 sprint suppression integration.
##
## Also covers stance toggle input encoding (D-053, #439).
## Spec refs: D-057, D-056, D-055, D-053, D-049
class_name TestInteractionList
extends GdUnitTestSuite
# -- Helpers -------------------------------------------------------------------
func _interaction_list_exists() -> bool:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
return true
return false
func _make_interaction_list() -> Node:
for path in ["res://ui/interaction_list.tscn", "res://ui/entity_interaction_list.tscn",
"res://scenes/interaction_list.tscn"]:
if ResourceLoader.exists(path):
var scene = load(path)
var node = scene.instantiate()
add_child(node)
return node
return null
func _make_list_or_skip() -> Node:
var list = _make_interaction_list()
if list == null:
push_warning("TestInteractionList: interaction list scene not found — test skipped (awaiting #432)")
return list
# -- Multi-verb rendering (D-057: 2-4 options max) ----------------------------
func test_list_renders_two_verbs() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
],
}]
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(2)
list.queue_free()
GameState.nearby_interactions = []
func test_list_renders_four_verbs_max() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: "2-4 options max"
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "ExamineNpc", "label": "Observe", "priority": 2, "available": true},
{"kind": "Confront", "label": "Confront", "priority": 3, "available": true},
{"kind": "Observe", "label": "Look at", "priority": 4, "available": true},
],
}]
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_less_equal(4)
list.queue_free()
GameState.nearby_interactions = []
func test_list_empty_when_no_interactions() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = []
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
func test_list_hidden_when_no_interactions() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = []
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
list.queue_free()
# -- Verb click dispatch (D-057) -----------------------------------------------
func test_get_selected_verb_returns_kind() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("get_selected_verb"):
# Default selection should be the first (highest priority) verb
assert_that(list.get_selected_verb()).is_equal("Talk")
list.queue_free()
GameState.nearby_interactions = []
func test_get_interaction_target_returns_entity_id() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 42, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("get_interaction_target"):
assert_that(list.get_interaction_target()).is_equal(42)
list.queue_free()
GameState.nearby_interactions = []
# -- Verb priority ordering (D-057: sorted by priority) -----------------------
func test_verbs_sorted_by_priority() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "ExamineNpc", "label": "Observe", "priority": 3, "available": true},
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
{"kind": "Confront", "label": "Confront", "priority": 2, "available": true},
],
}]
if list.has_method("update_from_state") and list.has_method("get_verb_labels"):
list.update_from_state()
var labels = list.get_verb_labels()
# Should be sorted: Talk (1), Confront (2), Observe (3)
if labels.size() >= 3:
assert_that(labels[0]).is_equal("Talk")
assert_that(labels[1]).is_equal("Confront")
assert_that(labels[2]).is_equal("Observe")
list.queue_free()
GameState.nearby_interactions = []
# -- D-055: Sprint suppression ------------------------------------------------
func test_sprint_suppresses_interaction_list() -> void:
# D-055: Sprint stance clears interaction buffer — no verbs should show
var list = _make_list_or_skip()
if list == null:
return
# Set up interactions
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
# Set stance to Sprint
GameState.player_stance = "Sprint"
if list.has_method("update_from_state"):
list.update_from_state()
# The list should be hidden when sprinting
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
elif list.has_method("get_visible_verb_count"):
assert_that(list.get_visible_verb_count()).is_equal(0)
list.queue_free()
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
func test_walk_shows_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
GameState.player_stance = "Walk"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_true()
list.queue_free()
GameState.nearby_interactions = []
func test_careful_shows_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
GameState.player_stance = "Careful"
if list.has_method("update_from_state"):
list.update_from_state()
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_true()
list.queue_free()
GameState.nearby_interactions = []
GameState.player_stance = "Walk"
# -- Z-layer compliance (D-049: interaction list on layer 6) ------------------
func test_interaction_list_z_layer() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: Labels render on insert overlay (CanvasLayer 10)
if list.has_method("get_z_layer"):
assert_that(list.get_z_layer()).is_equal(Constants.CANVAS_INSERT)
list.queue_free()
# -- Diegetic test (D-057: insert off → labels disappear) --------------------
func test_insert_off_hides_interaction_list() -> void:
var list = _make_list_or_skip()
if list == null:
return
# D-057: "If insert is off, labels disappear"
GameState.nearby_interactions = [{
"entity_id": 2, "entity_type": "Npc", "distance": 1,
"verbs": [
{"kind": "Talk", "label": "Talk", "priority": 1, "available": true},
],
}]
if list.has_method("set_insert_active"):
list.set_insert_active(false)
if list.has_method("is_showing"):
assert_that(list.is_showing()).is_false()
list.queue_free()
GameState.nearby_interactions = []
# -- Stance toggle wire mapping (D-053) ----------------------------------------
func test_stance_up_wire_mapping() -> void:
# Verify InputMapper.Action.TOGGLE_STANCE_UP maps to "ToggleStanceUp" wire name
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_UP)
assert_that(wire).is_equal("ToggleStanceUp")
func test_stance_down_wire_mapping() -> void:
var wire = SimBridge._action_enum_to_wire(InputMapper.Action.TOGGLE_STANCE_DOWN)
assert_that(wire).is_equal("ToggleStanceDown")
func test_all_movement_actions_have_wire_mapping() -> void:
# Verify no action produces an empty wire name (except OPEN_MENU which is client-only)
var actions_with_mapping := [
InputMapper.Action.MOVE_NORTH,
InputMapper.Action.MOVE_NORTHEAST,
InputMapper.Action.MOVE_EAST,
InputMapper.Action.MOVE_SOUTHEAST,
InputMapper.Action.MOVE_SOUTH,
InputMapper.Action.MOVE_SOUTHWEST,
InputMapper.Action.MOVE_WEST,
InputMapper.Action.MOVE_NORTHWEST,
InputMapper.Action.INTERACT,
InputMapper.Action.USE_PERCEPTION_MODE,
InputMapper.Action.PAUSE,
InputMapper.Action.TOGGLE_STANCE_UP,
InputMapper.Action.TOGGLE_STANCE_DOWN,
]
for action in actions_with_mapping:
var wire = SimBridge._action_enum_to_wire(action)
assert_that(wire.length()).is_greater(0)
# -- Contradicted entity indicator (D-057 + #422) ----------------------------
func test_contradicted_entity_verbs_decode() -> void:
# #422: NearbyInteraction.contradicted=true should be decodeable
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{
"entity_id": 2,
"entity_type": "Npc",
"distance": 1,
"verbs": [{"kind": "Talk", "label": "Talk", "priority": 1, "available": true}],
"contradicted": true,
}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
# Note: contradicted field may not be decoded yet in protocol.gd
# This test documents the expected behavior for #422 integration
# -- Object type verb sets (D-057 Phase 1) ------------------------------------
func test_object_type_verbs_decode() -> void:
# #421: ObjectType appears in NearbyInteraction.object_type
var raw := {
"tick": 1,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{
"entity_id": 3,
"entity_type": "Object",
"distance": 1,
"verbs": [
{"kind": "Open", "label": "Open", "priority": 1, "available": true},
{"kind": "Search", "label": "Search", "priority": 2, "available": true},
],
"object_type": "Container",
}],
}
var encoded = Messagepack.encode(raw)
var snapshot = Protocol.decode_snapshot(encoded.value)
assert_that(snapshot).is_not_null()
assert_that(snapshot.nearby_interactions.size()).is_equal(1)
assert_that(snapshot.nearby_interactions[0].verbs.size()).is_equal(2)
assert_that(snapshot.nearby_interactions[0].verbs[0].kind).is_equal("Open")
assert_that(snapshot.nearby_interactions[0].verbs[1].kind).is_equal("Search")
+8 -8
View File
@@ -11,7 +11,7 @@ extends GdUnitTestSuite
func test_protocol_decode_v4_with_nearby_interactions() -> void:
var raw := {
"tick": 10,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Player",
"visibility": "Forward", "relationship": "Unknown", "observation": "Visible"},
@@ -46,7 +46,7 @@ func test_protocol_decode_v4_with_nearby_interactions() -> void:
func test_protocol_decode_v4_no_nearby_interactions() -> void:
var raw := {
"tick": 5,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
}
var encoded = Messagepack.encode(raw)
@@ -57,7 +57,7 @@ func test_protocol_decode_v4_no_nearby_interactions() -> void:
func test_protocol_decode_empty_nearby_interactions() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [],
}
@@ -68,7 +68,7 @@ func test_protocol_decode_empty_nearby_interactions() -> void:
func test_protocol_decode_interaction_missing_verbs() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{"entity_id": 2}],
}
@@ -79,7 +79,7 @@ func test_protocol_decode_interaction_missing_verbs() -> void:
func test_protocol_decode_interaction_empty_verbs() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [],
"nearby_interactions": [{"entity_id": 2, "entity_type": "Npc", "distance": 1, "verbs": []}],
}
@@ -90,7 +90,7 @@ func test_protocol_decode_interaction_empty_verbs() -> void:
func test_protocol_decode_v4_entity_relationship() -> void:
var raw := {
"tick": 1,
"version": 4,
"version": Protocol.PROTOCOL_VERSION,
"entities": [
{"entity_id": 1, "x": 5.0, "y": 5.0, "z": 0, "kind": "Npc",
"visibility": "Forward", "relationship": "Friendly", "observation": "Visible"},
@@ -153,10 +153,10 @@ func test_sim_bridge_test_snapshot_interaction_at_range_2() -> void:
var snap = SimBridge._test_snapshot()
assert_that(snap.nearby_interactions.size()).is_equal(1)
func test_sim_bridge_test_snapshot_v4_version() -> void:
func test_sim_bridge_test_snapshot_protocol_version() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.version).is_equal(4)
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
# -- InteractionPrompt UI --
+1 -37
View File
@@ -4,7 +4,6 @@ class_name TestRendering
extends GdUnitTestSuite
var EntityRendererScript = load("res://scripts/rendering/entity_renderer.gd")
var FogRendererScript = load("res://scripts/rendering/fog_renderer.gd")
var TileRendererScript = load("res://scripts/rendering/tile_renderer.gd")
# -- Test data matching Protocol decoded format --
@@ -175,7 +174,7 @@ func test_sim_bridge_test_snapshot_has_v2_fields() -> void:
SimBridge.reset_test_state()
var snap = SimBridge._test_snapshot()
assert_that(snap.has("version")).is_true()
assert_that(snap.version).is_equal(4)
assert_that(snap.version).is_equal(Protocol.PROTOCOL_VERSION)
assert_that(snap.has("game_time")).is_true()
assert_that(snap.has("player_facing")).is_true()
assert_that(snap.has("visible_tiles")).is_true()
@@ -330,41 +329,6 @@ func test_entity_renderer_npc_has_no_facing_indicator() -> void:
renderer.queue_free()
# -- FogRenderer: position registration --
func _make_fog_renderer() -> TileMapLayer:
var fog = TileMapLayer.new()
fog.set_script(FogRendererScript)
return fog
func test_fog_renderer_registers_positions() -> void:
var fog := _make_fog_renderer()
fog.register_tile_positions(_test_tiles)
assert_that(fog._all_tile_positions.size()).is_equal(5)
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_true()
assert_that(fog._all_tile_positions.has(Vector2i(3, 0))).is_true()
assert_that(fog._all_tile_positions.has(Vector2i(0, 1))).is_true()
fog.free()
func test_fog_renderer_clears_on_re_register() -> void:
var fog := _make_fog_renderer()
fog.register_tile_positions(_test_tiles)
assert_that(fog._all_tile_positions.size()).is_equal(5)
fog.register_tile_positions([{"x": 10, "y": 10, "z": 0, "type": "floor"}])
assert_that(fog._all_tile_positions.size()).is_equal(1)
assert_that(fog._all_tile_positions.has(Vector2i(0, 0))).is_false()
fog.free()
func test_fog_renderer_handles_empty_data() -> void:
var fog := _make_fog_renderer()
fog._initialized = true
fog.update_fog({}, Vector2.ZERO)
fog.register_tile_positions([])
assert_that(fog._all_tile_positions.size()).is_equal(0)
fog.free()
# -- TileRenderer: tile type constants --
func test_tile_type_map_covers_required_types() -> void:
+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")