Option (a): cursor shape still changes (body orients to targets), but verb labels and interaction prompts are suppressed when insert_active is false. Amends D-056 and D-057 with resolution note. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
323 lines
9.6 KiB
GDScript
323 lines
9.6 KiB
GDScript
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
|
|
# OQ-07 (#522): when false, verb labels are suppressed (should_show_interactions → false).
|
|
# Cursor shape transitions still fire — the character's body still orients to targets.
|
|
var insert_active: bool = true
|
|
|
|
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 = Constants.color_for_entity_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:
|
|
# _hover_offset is computed in _detect_hover() during _process(), not recalculated
|
|
# here. This is safe because Camera2D (tree sibling, earlier in scene order) applies
|
|
# its smoothing transform before CursorRenderer._process() reads canvas_transform.
|
|
# Nothing modifies the canvas transform between _process() and _draw().
|
|
|
|
# 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: Array[Vector2] = [Vector2.UP, Vector2.RIGHT, Vector2.DOWN, Vector2.LEFT]
|
|
for dir: Vector2 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:
|
|
# OQ-07: insert off suppresses verb labels even though cursor shape still changes
|
|
if not insert_active:
|
|
return false
|
|
return not weapon_mode_active or _shift_held
|
|
|
|
|
|
func set_insert_active(active: bool) -> void:
|
|
insert_active = active
|
|
|
|
|
|
func get_interaction_range() -> int:
|
|
return 2 # D-056: ~2 sim tiles
|