Files
settled-reach/client/scripts/rendering/cursor_renderer.gd
T
jpmschweitzerandClaude Opus 4.6 8bf7f6e610 fix(client): entity lerp, fog shader safety, type fixes
Entity renderer: add framerate-independent position lerping so entities
slide between tiles instead of snapping. Tuned for Sprint snappiness
and Walk/Careful fluidity.

Fog shader: set ColorRect to transparent fallback so a shader load
failure doesn't paint solid white over the world. Track camera position
(not player position) so fog stays synced during smooth camera pan.
Restructure GLSL to avoid early return (some GPU drivers miscompile it).

Minor type fixes: typed Array[Vector2] in cursor_renderer tick drawing,
untyped Array in inventory_grid to avoid Godot typed-array cast issues.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 23:26:23 +01:00

313 lines
9.2 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
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:
return not weapon_mode_active or _shift_held
func get_interaction_range() -> int:
return 2 # D-056: ~2 sim tiles