class_name FollowCamera3D extends Node3D ## T-1088 follow camera (design §7) — the D-148 orthographic rig with D-015 locked ## follow. Proto-production: values/technique migrate at the T-962 gate. ## ## Node contract: attach to a Node3D rig OUTSIDE WorldRoot (the .tscn CameraRig). ## The rig's global position is the smoothed pivot; a Camera3D child (adopted if one ## named "Camera3D" exists, created in code otherwise — every property is rewritten ## from constants in _ready, so .tscn values cannot drift) orbits the pivot via the ## spike math: camera.position = pivot + basis * Vector3(0, 0, CAM_DIST), where ## basis is a pure pitch rotation. Yaw is locked to 0 ALWAYS — this file has no ## rotation input path by design: stepped camera yaw is OUT until a D/Q record ## exists (design-input §3); the diamond view is WorldRoot's 45°, never the camera. ## ## The pivot follow is exponential — deliberately the ONLY soft layer in the stack ## (design §0). Against the rig's constant-velocity legs it converges to a constant ## trailing offset velocity/CAM_FOLLOW_RATE (Walk ~0.21 m, Sprint ~0.42 m): reads ## as speed, zero jitter. The character itself is never eased (locomotion_rig.gd). ## ## Dev affordances (sanctioned, D-148/D-158): T cycles pitch presets ## [-30 gameplay default, -5 frontal, -80 overhead] (the spike's -45 iso preset is ## struck by D-148); scroll wheel zooms ortho size 6-14. No "camera_tilt" action ## exists in project.godot, so T is a direct physical-key check in ## _unhandled_input — UI-safe: events consumed by Control nodes never reach it. ## ## Teleport snaps: connect the locomotion rig's `teleported` signal to ## snap_to_target() (use .unbind(n) if the signal carries arguments); the sandbox ## root also calls snap_to_target() from its first-snapshot latch. const CAM_NEAR := 0.1 const CAM_FAR := 100.0 # Scroll zoom — sandbox-only dev affordance (design §7: ungoverned and kept that # way), so its knobs live here rather than in SandboxConstants. const ZOOM_MIN := 6.0 const ZOOM_MAX := 14.0 const ZOOM_STEP := 1.0 const ZOOM_RATE := 8.0 ## The node the pivot chases — the sandbox wires WorldRoot/PlayerRig here, so the ## followed point is the rig's interpolated position with the 45° map rotation ## already applied (design §1.2). @export var follow_target: Node3D = null var _camera: Camera3D = null var _pivot: Vector3 = Vector3.ZERO var _preset_idx: int = 0 # index into SandboxConstants.CAM_PITCH_PRESETS; 0 = -30 default var _pitch_deg: float = 0.0 var _target_ortho_size: float = 0.0 # Target velocity estimate, only consumed by the LOOKAHEAD_S knob (0.0 for now — # design §7 exposes it for the tuning pass). var _last_target_pos: Vector3 = Vector3.ZERO var _target_velocity: Vector3 = Vector3.ZERO var _has_target_history: bool = false func _ready() -> void: _camera = get_node_or_null("Camera3D") as Camera3D if _camera == null: _camera = Camera3D.new() _camera.name = "Camera3D" add_child(_camera) _camera.projection = Camera3D.PROJECTION_ORTHOGONAL _camera.size = SandboxConstants.CAM_ORTHO_SIZE _camera.near = CAM_NEAR _camera.far = CAM_FAR _camera.current = true _pitch_deg = float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx]) _target_ortho_size = SandboxConstants.CAM_ORTHO_SIZE _pivot = follow_target.global_position if follow_target != null else global_position global_position = _pivot _apply_orbit() func _process(delta: float) -> void: if follow_target != null: var target_pos := follow_target.global_position if _has_target_history and delta > 0.0: var frame_delta := target_pos - _last_target_pos if frame_delta.length() > SandboxConstants.SNAP_DIST_M: # Teleport-sized jump: don't launch the lookahead; the rig's # `teleported` signal hard-snaps the pivot (design §7). _target_velocity = Vector3.ZERO else: _target_velocity = frame_delta / delta _last_target_pos = target_pos _has_target_history = true var look_point := target_pos + _target_velocity * SandboxConstants.LOOKAHEAD_S # Exponential pivot follow — frame-rate independent (1 - exp(-rate * dt)). _pivot = _pivot.lerp(look_point, 1.0 - exp(-SandboxConstants.CAM_FOLLOW_RATE * delta)) global_position = _pivot var target_pitch := float(SandboxConstants.CAM_PITCH_PRESETS[_preset_idx]) _pitch_deg = lerpf(_pitch_deg, target_pitch, 1.0 - exp(-SandboxConstants.CAM_TILT_RATE * delta)) _camera.size = lerpf(_camera.size, _target_ortho_size, 1.0 - exp(-ZOOM_RATE * delta)) _apply_orbit() ## Current (smoothed) pitch in degrees from horizontal — consumed by the ## cutaway corridor reach (sandbox root pushes WALL_H / tan(pitch) per frame). func pitch_deg() -> float: return _pitch_deg # Dev affordances only — there is deliberately NO yaw/rotation input path here. func _unhandled_input(event: InputEvent) -> void: if event is InputEventKey: var key := event as InputEventKey # Direct physical-key check: no "camera_tilt" action exists in project.godot. # Physical T (84) is unbound in the input map, so no action conflicts. if key.pressed and not key.echo and key.physical_keycode == KEY_T: _preset_idx = (_preset_idx + 1) % SandboxConstants.CAM_PITCH_PRESETS.size() get_viewport().set_input_as_handled() elif event is InputEventMouseButton: var mb := event as InputEventMouseButton if mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_UP: _nudge_zoom(-ZOOM_STEP) get_viewport().set_input_as_handled() elif mb.pressed and mb.button_index == MOUSE_BUTTON_WHEEL_DOWN: _nudge_zoom(ZOOM_STEP) get_viewport().set_input_as_handled() ## Hard pivot snap onto follow_target — no glide (design §7). Called by the sandbox ## root's first-snapshot latch and by the rig's `teleported` signal. Pitch and zoom ## are deliberately untouched: a cross-map jump must not change the dev view. func snap_to_target() -> void: if follow_target == null: return _pivot = follow_target.global_position global_position = _pivot _last_target_pos = _pivot _target_velocity = Vector3.ZERO _has_target_history = true ## The managed Camera3D child — for the DebugHud / visual-capture harness. func get_camera() -> Camera3D: return _camera ## Spike pivot-orbit math (design §7): the camera's offset from the pivot for a ## given pitch, camera.position = pivot + basis * Vector3(0, 0, dist). Static and ## pure for golden tests. At -30°/30 m this is (0, 15, 25.98) — the boom rises as ## the pitch steepens while the look-at point stays exactly on the pivot. static func orbit_offset(pitch_deg: float, dist: float) -> Vector3: return Basis(Vector3.RIGHT, deg_to_rad(pitch_deg)) * Vector3(0.0, 0.0, dist) # Position the camera on the orbit and pitch it at the pivot. Yaw stays 0: the # rig node is never rotated and the camera's rotation is pitch-only by # construction. func _apply_orbit() -> void: var pitch_rad := deg_to_rad(_pitch_deg) _camera.position = orbit_offset(_pitch_deg, SandboxConstants.CAM_DIST) _camera.rotation = Vector3(pitch_rad, 0.0, 0.0) func _nudge_zoom(step: float) -> void: _target_ortho_size = clampf(_target_ortho_size + step, ZOOM_MIN, ZOOM_MAX)