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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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
@@ -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"] = {
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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"):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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 --
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 {}
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user