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>
313 lines
9.2 KiB
GDScript
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
|