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>
161 lines
7.0 KiB
GDScript
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)
|