Files
settled-reach/client/scripts/sandbox/follow_camera_3d.gd
T
jpmschweitzerandClaude Fable 5 2db7dd25e1 fix(client): cutaway cuts the camera-character sightline corridor, not a proximity dome (T-1088)
Live-session finding: the half-disc cut bit nearby side walls while leaving
the wall actually occluding the character at full height, with dome-contour
scallops across the two-row walls. Rewritten as a sightline corridor: walls
within CUT_CORRIDOR_HALF_W of the character's x, camera-side, out to a
pitch-derived reach (WALL_H / tan(pitch) — how far a wall can still occlude
in ortho; ~4.3 m at -30, breathes with the T-cycle tilt) drop to the stub.
FollowCamera3D exposes pitch_deg(); reach clamped 0.5-16 m.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 19:01:49 +02:00

161 lines
7.0 KiB
GDScript

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)