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
+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"):