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]. const CameraStateScript := preload("res://scripts/camera_state.gd") const ZoomBandConfigScript := preload("res://scripts/zoom_band_config.gd") ## TODO(E9.M1): throttled `camera_zoom_changed` telemetry when schema exists. signal zoom_band_changed(new_index: int, distance: float) ## 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) 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 dist: float = _current_follow_distance() var desired: Vector3 = desired_eye_world( focus, 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) 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 zoom_band_changed.emit(_zoom_band_index, _current_follow_distance()) 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 ) 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 return zoom_cfg.distance_at(band_index) 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 = _current_follow_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))