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