neon-sprawl/client/scripts/isometric_follow_camera.gd

199 lines
6.5 KiB
GDScript

extends Node3D
## NEON-25: client-local isometric follow; updates [CameraState] each `_process`.
## NEON-26: discrete zoom bands via [member zoom_band_config]; wheel / `camera_zoom_*` actions.
## 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].
## TODO(E9.M1): map throttled product telemetry (for example `camera_zoom_changed`)
## to this signal when schema exists.
signal zoom_band_changed(new_index: int, distance: float)
const CameraStateScript := preload("res://scripts/camera_state.gd")
const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd")
## Tuned to match pre-NEON-25 static `Camera3D` at (12,10,12) vs player at (-5,0.9,-5).
## When [member zoom_band_config] is null or has no bands, this is the sole follow distance.
@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
## Designer-tunable discrete bands (`res://resources/isometric_zoom_bands.tres` on main rig).
@export var zoom_band_config: Resource
@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 _zoom_band_index: int = 0
var _warned_missing_follow_target: bool = false
@onready var camera: Camera3D = $Camera3D
func _ready() -> void:
_state = CameraStateScript.new()
_init_zoom_band_index()
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
var dist0: float = _current_follow_distance()
_smoothed_eye = desired_eye_world(focus0, dist0, deg_to_rad(pitch_elevation_deg), yaw0)
global_position = _smoothed_eye
camera.look_at(focus0, Vector3.UP)
_sync_camera_state(focus0, dist0)
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 effective_dist: float = _current_follow_distance()
var desired: Vector3 = desired_eye_world(
focus, effective_dist, 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, effective_dist)
func _unhandled_input(event: InputEvent) -> void:
if event.is_echo():
return
if event.is_action_pressed("camera_zoom_in"):
_apply_zoom_step(-1)
elif event.is_action_pressed("camera_zoom_out"):
_apply_zoom_step(1)
## **Near** = lower band index. Zoom in → closer → decrease index (clamped).
func _apply_zoom_step(delta_bands: int) -> void:
if not _zoom_config_valid():
return
var cfg: Resource = zoom_band_config
var new_idx: int = cfg.clamp_index(_zoom_band_index + delta_bands)
if new_idx == _zoom_band_index:
return
_zoom_band_index = new_idx
var d: float = _current_follow_distance()
zoom_band_changed.emit(_zoom_band_index, d)
func _init_zoom_band_index() -> void:
_zoom_band_index = 0
if not _zoom_config_valid():
return
var cfg: Resource = zoom_band_config
_zoom_band_index = cfg.clamp_index(cfg.default_band_index)
func _zoom_config_valid() -> bool:
return (
zoom_band_config != null
and zoom_band_config.get_script() == ZoomBandConfigScript
and zoom_band_config.band_count() > 0
and zoom_band_config.all_band_distances_positive()
)
func _current_follow_distance() -> float:
return effective_follow_distance(zoom_band_config, _zoom_band_index, follow_distance)
static func effective_follow_distance(
zoom_cfg: Resource, band_index: int, fallback_distance: float
) -> float:
if zoom_cfg == null or zoom_cfg.get_script() != ZoomBandConfigScript:
return fallback_distance
if zoom_cfg.band_count() == 0:
return fallback_distance
if not zoom_cfg.all_band_distances_positive():
return fallback_distance
return zoom_cfg.distance_at(band_index)
func _sync_camera_state(focus: Vector3, effective_distance: float) -> void:
# `CameraState.yaw` = orbit delta only; world-fixed diagonal framing =
# `presentation_yaw_deg`.
_state.follow_target_path = follow_target_path
_state.distance = effective_distance
_state.zoom_band_index = _zoom_band_index if _zoom_config_valid() else 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))