134 lines
4.1 KiB
GDScript
134 lines
4.1 KiB
GDScript
extends Node3D
|
|
|
|
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
|
|
## Pitch / roll are fixed by presentation exports; **orbit yaw** in state stays **0** while
|
|
## [member allow_yaw] is false (no rotate input bound). Future orbit: read relative input here,
|
|
## add to `_orbit_yaw_rad`, clamp with [member max_yaw_deg].
|
|
|
|
const CameraStateScript := preload("res://scripts/camera_state.gd")
|
|
|
|
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
|
|
@export var follow_target_path: NodePath = NodePath("../Player")
|
|
@export var follow_distance: float = 25.709
|
|
@export var pitch_elevation_deg: float = 20.693
|
|
@export var presentation_yaw_deg: float = 45.0
|
|
@export var focus_vertical_offset: float = 0.0
|
|
|
|
@export var allow_yaw: bool = false
|
|
@export var max_yaw_deg: float = 45.0
|
|
|
|
## Higher = snappier follow (`1 - exp(-smoothness * delta)`).
|
|
@export var follow_smoothness: float = 12.0
|
|
## When eye-to-desired distance exceeds this, snap (server snap / huge teleports).
|
|
@export var snap_distance: float = 24.0
|
|
|
|
## Latest [CameraState] tick; same object each frame (see NEON-25 plan).
|
|
var camera_state:
|
|
get:
|
|
return _state
|
|
|
|
var _state = null
|
|
var _smoothed_eye: Vector3 = Vector3.ZERO
|
|
var _orbit_yaw_rad: float = 0.0
|
|
var _warned_missing_follow_target: bool = false
|
|
|
|
@onready var camera: Camera3D = $Camera3D
|
|
|
|
|
|
func _ready() -> void:
|
|
_state = CameraStateScript.new()
|
|
if camera == null:
|
|
push_error("IsometricFollowCamera: expected child Camera3D")
|
|
return
|
|
var t: Node3D = _resolve_target()
|
|
if t != null:
|
|
if not allow_yaw:
|
|
_orbit_yaw_rad = 0.0
|
|
else:
|
|
_orbit_yaw_rad = clampf(
|
|
_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
|
|
)
|
|
var focus0: Vector3 = t.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
|
|
var yaw0: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
|
|
_smoothed_eye = desired_eye_world(
|
|
focus0, follow_distance, deg_to_rad(pitch_elevation_deg), yaw0
|
|
)
|
|
global_position = _smoothed_eye
|
|
camera.look_at(focus0, Vector3.UP)
|
|
_sync_camera_state(focus0)
|
|
else:
|
|
_smoothed_eye = global_position
|
|
_warn_missing_follow_target_once()
|
|
|
|
|
|
func _process(delta: float) -> void:
|
|
if camera == null or _state == null:
|
|
return
|
|
var target: Node3D = _resolve_target()
|
|
if target == null:
|
|
# No eye/state update until the path resolves — avoids chasing a freed or miswired node.
|
|
_warn_missing_follow_target_once()
|
|
return
|
|
_warned_missing_follow_target = false
|
|
|
|
if not allow_yaw:
|
|
_orbit_yaw_rad = 0.0
|
|
else:
|
|
_orbit_yaw_rad = clampf(
|
|
_orbit_yaw_rad, -deg_to_rad(max_yaw_deg), deg_to_rad(max_yaw_deg)
|
|
)
|
|
|
|
var focus: Vector3 = target.global_position + Vector3(0.0, focus_vertical_offset, 0.0)
|
|
var yaw_total: float = deg_to_rad(presentation_yaw_deg) + _orbit_yaw_rad
|
|
var desired: Vector3 = desired_eye_world(
|
|
focus, follow_distance, deg_to_rad(pitch_elevation_deg), yaw_total
|
|
)
|
|
|
|
if _smoothed_eye.distance_to(desired) >= snap_distance:
|
|
_smoothed_eye = desired
|
|
else:
|
|
var k: float = 1.0 - exp(-follow_smoothness * delta)
|
|
_smoothed_eye = _smoothed_eye.lerp(desired, k)
|
|
|
|
global_position = _smoothed_eye
|
|
camera.look_at(focus, Vector3.UP)
|
|
_sync_camera_state(focus)
|
|
|
|
|
|
func _sync_camera_state(focus: Vector3) -> void:
|
|
# `CameraState.yaw` = orbit delta only; world-fixed diagonal framing =
|
|
# `presentation_yaw_deg`.
|
|
_state.follow_target_path = follow_target_path
|
|
_state.distance = follow_distance
|
|
_state.zoom_band_index = 0
|
|
_state.focus_world = focus
|
|
_state.yaw = _orbit_yaw_rad
|
|
|
|
|
|
func _resolve_target() -> Node3D:
|
|
if follow_target_path.is_empty():
|
|
return null
|
|
var n: Node = get_node_or_null(follow_target_path)
|
|
if n is Node3D:
|
|
return n as Node3D
|
|
return null
|
|
|
|
|
|
func _warn_missing_follow_target_once() -> void:
|
|
if _warned_missing_follow_target:
|
|
return
|
|
_warned_missing_follow_target = true
|
|
push_warning(
|
|
"IsometricFollowCamera: follow_target_path is empty or does not resolve to a Node3D; "
|
|
+ "camera not updating."
|
|
)
|
|
|
|
|
|
static func desired_eye_world(
|
|
focus: Vector3, distance: float, pitch_elevation_rad: float, yaw_rad: float
|
|
) -> Vector3:
|
|
var h: float = distance * cos(pitch_elevation_rad)
|
|
return focus + Vector3(
|
|
h * sin(yaw_rad), distance * sin(pitch_elevation_rad), h * cos(yaw_rad)
|
|
)
|