
Godot Camera Systems
- 280 installs
- 454 repo stars
- Updated July 28, 2026
- thedivergentai/gd-agentic-skills
Use godot-camera-systems for development tasks
About
godot-camera-systems: A skill for development. This provides functionality for development workflows.
- godot-camera-systems
Godot Camera Systems by the numbers
- 280 all-time installs (skills.sh)
- +27 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #1,402 of 4,347 Backend & APIs skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/thedivergentai/gd-agentic-skills --skill godot-camera-systemsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 280 |
|---|---|
| repo stars | ★ 454 |
| Last updated | July 28, 2026 |
| Repository | thedivergentai/gd-agentic-skills ↗ |
What it does
Use godot-camera-systems for development tasks
Files
Camera Systems
Expert guidance for creating smooth, responsive cameras in 2D and 3D games.
NEVER Do
- NEVER use `global_position = target.global_position` every frame — Instant position matching causes jittery movement. Use
lerp()orposition_smoothing_enabled = true[12]. - NEVER use `offset` for permanent camera positioning —
offsetis for shake, sway, or temporary recoil effects only. Usepositionfor permanent framing to avoid logic conflicts [14]. - NEVER forget `limit_smoothed = true` for `Camera2D` — Hard boundaries cause jarring visual stops. Smoothing against limits ensures a professional feel [13].
- NEVER enable multiple `Camera2D` nodes in the same viewport simultaneously — Only the last enabled camera takes precedence. Explicitly disable inactive cameras [15].
- NEVER use `SpringArm3D` without a collision mask — It will clip through terrain and walls. Set it to the world/environment layer [16].
- NEVER implement screen shake by randomizing `position` directly — This overwrites follow-logic. Use
offsetor a dedicated Trauma/Noise system to Layer shake over the follow-position [27, 28]. - NEVER parent the Camera directly to a high-speed physics body — Physics stutter or parent rotation will cause motion sickness. Use
RemoteTransform2D/3Dwith rotation sync disabled for a stable view [30]. - NEVER use `look_at()` in 3D without a fallback for the 'Up' vector — If the target is directly above/below, the camera will flip wildly. Use guards or
Quaternionmath for vertical tracking. - NEVER rely on `SubViewport` defaults for Mini-maps — Viewports are expensive; explicitly set
render_target_update_modetoUPDATE_WHEN_VISIBLEor a fixed lower framerate to save GPU [156]. - NEVER use linear interpolation for Zoom — It feels 'robotic'. Use exponential lerp or a
TweenwithTRANS_CUBICfor a more natural tactical feel.
---
Available Scripts
MANDATORY: Read before implementing camera behaviors.
camera_shake_trauma_pro.gd
Advanced noise-based screenshake (Trauma system) for organic, non-jittery explosions and impacts.
cinematic_framing_logic.gd
Rule of Thirds and Lead Room management in code, ensuring high-quality cinematic composition.
camera_state_machine.gd
Managing transitions between 'Follow', 'Static', and 'Cinematic' camera states with Tweens.
minimap_viewport_manager.gd
SubViewport optimization for Mini-maps and UI overlays. Reduces render updates for better FPS.
split_screen_setup.gd
Dynamic split-screen architecture for local multiplayer, handling viewport stretching and audio listeners.
remote_transform_decoupling.gd
Decoupling camera position from player rotation/scale using RemoteTransform2D for high-speed stability.
zoom_damping_controller.gd
Non-linear, smooth zoom logic with tactical overview bounds and mouse-wheel support.
spring_lerp_camera_3d.gd
Physics-stable 3D follow camera using spring-mass interpolation to reduce follow-latency jitter.
first_person_sway.gd
Procedural 8-figure head bob and weapon sway logic for immersive First-Person systems.
deadzone_drag_margins.gd
Platformer-specific deadzone management using code to control follow-margins and drag-center behavior.
---
Camera2D Basics
extends Camera2D
@export var target: Node2D
@export var follow_speed := 5.0
func _process(delta: float) -> void:
if target:
global_position = global_position.lerp(
target.global_position,
follow_speed * delta
)Position Smoothing
extends Camera2D
func _ready() -> void:
# Built-in smoothing
position_smoothing_enabled = true
position_smoothing_speed = 5.0Camera Limits
extends Camera2D
func _ready() -> void:
# Constrain camera to level bounds
limit_left = 0
limit_top = 0
limit_right = 1920
limit_bottom = 1080
# Smooth against limits
limit_smoothed = trueCamera Shake
extends Camera2D
var shake_amount := 0.0
var shake_decay := 5.0
func _process(delta: float) -> void:
if shake_amount > 0:
shake_amount = max(shake_amount - shake_decay * delta, 0)
offset = Vector2(
randf_range(-shake_amount, shake_amount),
randf_range(-shake_amount, shake_amount)
)
else:
offset = Vector2.ZERO
func shake(intensity: float) -> void:
shake_amount = intensity
# Usage:
$Camera2D.shake(10.0) # Screen shake on explosionZoom Controls
extends Camera2D
@export var zoom_speed := 0.1
@export var min_zoom := 0.5
@export var max_zoom := 2.0
func _unhandled_input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
zoom_in()
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
zoom_out()
func zoom_in() -> void:
zoom = zoom.move_toward(
Vector2.ONE * max_zoom,
zoom_speed
)
func zoom_out() -> void:
zoom = zoom.move_toward(
Vector2.ONE * min_zoom,
zoom_speed
)Look-Ahead Camera
extends Camera2D
@export var look_ahead_distance := 50.0
@export var target: CharacterBody2D
func _process(delta: float) -> void:
if target:
var look_ahead := target.velocity.normalized() * look_ahead_distance
global_position = target.global_position + look_aheadSplit-Screen (Multiple Cameras)
# Player 1 Camera
@onready var cam1: Camera2D = $Player1/Camera2D
# Player 2 Camera
@onready var cam2: Camera2D = $Player2/Camera2D
func _ready() -> void:
# Split viewport
cam1.anchor_mode = Camera2D.ANCHOR_MODE_DRAG_CENTER
cam2.anchor_mode = Camera2D.ANCHOR_MODE_DRAG_CENTERCamera3D Patterns
Third-Person Camera
extends Camera3D
@export var target: Node3D
@export var distance := 5.0
@export var height := 2.0
@export var rotation_speed := 3.0
var rotation_angle := 0.0
func _process(delta: float) -> void:
if not target:
return
# Rotate around target
rotation_angle += Input.get_axis("camera_left", "camera_right") * rotation_speed * delta
# Calculate position
var offset := Vector3(
sin(rotation_angle) * distance,
height,
cos(rotation_angle) * distance
)
global_position = target.global_position + offset
look_at(target.global_position, Vector3.UP)First-Person Camera
extends Camera3D
@export var mouse_sensitivity := 0.002
@export var max_pitch := deg_to_rad(80)
var pitch := 0.0
func _ready() -> void:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func _input(event: InputEvent) -> void:
if event is InputEventMouseMotion:
# Yaw (horizontal)
get_parent().rotate_y(-event.relative.x * mouse_sensitivity)
# Pitch (vertical)
pitch -= event.relative.y * mouse_sensitivity
pitch = clamp(pitch, -max_pitch, max_pitch)
rotation.x = pitchCamera Transitions
# Smooth camera position change
func move_to_position(target_pos: Vector2, duration: float = 1.0) -> void:
var tween := create_tween()
tween.tween_property(self, "global_position", target_pos, duration)
tween.set_ease(Tween.EASE_IN_OUT)
tween.set_trans(Tween.TRANS_CUBIC)Cinematic Cameras
# Camera path following
extends Path2D
@onready var path_follow: PathFollow2D = $PathFollow2D
@onready var camera: Camera2D = $PathFollow2D/Camera2D
func play_cutscene(duration: float) -> void:
var tween := create_tween()
tween.tween_property(path_follow, "progress_ratio", 1.0, duration)
await tween.finishedBest Practices
1. One Active Camera
# Only one Camera2D should be enabled at a time
# Others should have enabled = false2. Parent Camera to Player
# Scene structure:
# Player (CharacterBody2D)
# └─ Camera2D3. Use Anchors for UI
# Camera doesn't affect UI positioned with anchors
# UI stays in screen space4. Deadzone for Platformers
extends Camera2D
func _ready() -> void:
drag_horizontal_enabled = true
drag_vertical_enabled = true
drag_left_margin = 0.3
drag_right_margin = 0.3Expert Camera Architectures
1. Camera Framing Box (Multi-Target Framing)
To handle multiple targets in a single frame (e.g., Smash Bros or Local-Coop), calculate the AABB (Axis-Aligned Bounding Box) of all targets. Interpolate the camera's global_position to the center of the box and adjust the zoom (2D) or distance (3D) to encapsulate the entire box with a padding margin.
class_name FramingBoxCamera2D extends Camera2D
## Dynamically zooms and pans to frame multiple targets.
@export var targets: Array[Node2D] = []
@export var margin: float = 100.0
@export var min_zoom: float = 0.5
@export var max_zoom: float = 2.0
func _physics_process(_delta: float) -> void:
if targets.is_empty(): return
# 1. Calculate the bounding box of all targets.
var rect := Rect2(targets[0].global_position, Vector2.ZERO)
for target in targets:
rect = rect.expand(target.global_position)
# 2. Add padding.
rect = rect.grow(margin)
# 3. Position the camera at the center.
global_position = rect.get_center()
# 4. Calculate required zoom level to fit the box.
var screen_size := get_viewport_rect().size
var zoom_x := screen_size.x / rect.size.x
var zoom_y := screen_size.y / rect.size.y
var target_zoom := clampf(min(zoom_x, zoom_y), min_zoom, max_zoom)
zoom = Vector2.ONE * target_zoom2. Camera Raycasting (SpringArm3D / RayCast3D)
To prevent the camera from clipping through terrain in 3D, use a SpringArm3D node. For custom camera logic, query the physics space directly using intersect_ray(). This allows you to smoothly interpolate the camera closer to the player when an obstruction (e.g., a wall) is detected between the camera's desired position and the target.
class_name OcclusionAwareCamera3D extends Camera3D
## Prevents camera clipping via manual physics space raycasting.
@export var target: Node3D
@export var ideal_distance: float = 5.0
func _physics_process(_delta: float) -> void:
if not target: return
var space_state := get_world_3d().direct_space_state
var desired_pos := target.global_position + (Vector3.BACK * ideal_distance)
# Query for obstructions between the target and the desired camera position.
var query := PhysicsRayQueryParameters3D.create(target.global_position, desired_pos)
query.exclude = [target.get_rid()]
var result: Dictionary = space_state.intersect_ray(query)
if not result.is_empty():
# Move the camera to the hit position (with a small offset to prevent clipping).
global_position = result.position + result.normal * 0.2
else:
global_position = desired_pos
look_at(target.global_position)3. Screenshake Audit (Trauma Decay Profiler)
A "Screenshake Audit" involves visualizing the trauma-decay curve over time. By plotting the current_trauma value to a graph using the CanvasItem._draw() API, you can precisely tune the "punchiness" and "decay duration" of impacts to ensure they feel intentional rather than random noise.
class_name TraumaDebugger extends Node2D
## Visualizes the decay curve of a trauma-based shake system.
@export var camera: ProceduralScreenShake
var _history: PackedFloat32Array = []
func _process(_delta: float) -> void:
if not camera: return
_history.append(camera.get_current_trauma())
if _history.size() > 200: _history.remove_at(0)
queue_redraw()
func _draw() -> void:
var width := 400.0
var height := 100.0
var step := width / 200.0
for i in range(1, _history.size()):
var p1 := Vector2(i * step, height - (_history[i-1] * height))
var p2 := Vector2((i+1) * step, height - (_history[i] * height))
draw_line(p1, p2, Color.YELLOW, 2.0)Reference
Related
- Master Skill: godot-master
extends Camera2D
## Expert Camera2D follow script with look-ahead prediction and deadzones.
## Attachment: Add as child of the level OR parent to player and set top_level = true.
@export_group("Targeting")
@export var target: Node2D
@export var look_ahead_enabled: bool = true
@export var look_ahead_distance: float = 120.0
@export var look_ahead_speed: float = 2.0
@export_group("Smoothing")
@export var follow_smoothing: float = 5.0
@export var velocity_smoothing: float = 2.0
var _target_velocity: Vector2 = Vector2.ZERO
var _last_target_pos: Vector2 = Vector2.ZERO
func _ready() -> void:
if not target:
push_warning("CameraFollow2D: No target assigned.")
# Enable built-in smoothing as base
position_smoothing_enabled = true
position_smoothing_speed = follow_smoothing
func _process(delta: float) -> void:
if not target:
return
var target_pos = target.global_position
if look_ahead_enabled:
# Calculate target velocity if it's not a CharacterBody
var current_velocity = (target_pos - _last_target_pos) / delta if delta > 0 else Vector2.ZERO
if target is CharacterBody2D:
current_velocity = target.get_real_velocity()
_target_velocity = _target_velocity.lerp(current_velocity, velocity_smoothing * delta)
_last_target_pos = target_pos
# Apply look-ahead offset
var offset_vec = _target_velocity.normalized() * look_ahead_distance
target_pos += offset_vec
global_position = target_pos
## Helper to set camera limits from a ColorRect or reference shape
func set_limits_from_rect(rect: Rect2) -> void:
limit_left = int(rect.position.x)
limit_top = int(rect.position.y)
limit_right = int(rect.end.x)
limit_bottom = int(rect.end.y)
# camera_shake_trauma_pro.gd
# Advanced trauma-based screenshake using noise [27]
extends Camera2D
# EXPERT NOTE: Noise-based shake is superior to random offsets as
# it prevents high-frequency jitter and feels more organic.
@export var trauma_reduction_rate: float = 1.0
@export var max_offset: Vector2 = Vector2(100, 75)
@export var max_roll: float = 0.1
var trauma: float = 0.0 # 0.0 to 1.0
var noise: FastNoiseLite = FastNoiseLite.new()
var noise_y: int = 0
func _ready() -> void:
noise.seed = randi()
noise.frequency = 0.5
func add_trauma(amount: float) -> void:
trauma = clamp(trauma + amount, 0.0, 1.0)
func _process(delta: float) -> void:
if trauma > 0:
trauma = max(trauma - trauma_reduction_rate * delta, 0)
_execute_shake()
func _execute_shake() -> void:
# Using squared trauma makes the shake feel more explosive [28]
var shake = trauma * trauma
noise_y += 1
rotation = max_roll * shake * noise.get_noise_2d(noise.seed, noise_y)
offset.x = max_offset.x * shake * noise.get_noise_2d(noise.seed * 2, noise_y)
offset.y = max_offset.y * shake * noise.get_noise_2d(noise.seed * 3, noise_y)
# skills/camera-systems/scripts/camera_shake_trauma.gd
extends Camera2D
## Trauma-Based Camera Shake Expert Pattern
## Perlin-noise powered shake that degrades naturally over time.
class_name CameraShakeTrauma
@export var max_offset := 100.0
@export var max_rotation := 10.0 # degrees
@export var trauma_power := 2.0
@export var trauma_decay := 1.0
var trauma := 0.0
var _noise := FastNoiseLite.new()
var _noise_seed := randi()
func _ready() -> void:
_noise.seed = _noise_seed
_noise.frequency = 4.0
func _process(delta: float) -> void:
if trauma > 0:
trauma = max(trauma - trauma_decay * delta, 0.0)
_apply_shake()
else:
offset = Vector2.ZERO
rotation = 0.0
func add_trauma(amount: float) -> void:
trauma = min(trauma + amount, 1.0)
func _apply_shake() -> void:
var shake_amount := pow(trauma, trauma_power)
# Use time-based noise for smooth shake
var time := Time.get_ticks_msec() / 1000.0
offset.x = max_offset * shake_amount * _noise.get_noise_2d(_noise_seed, time)
offset.y = max_offset * shake_amount * _noise.get_noise_2d(_noise_seed + 1, time)
rotation_degrees = max_rotation * shake_amount * _noise.get_noise_2d(_noise_seed + 2, time)
## EXPERT USAGE:
## Extend Camera2D with this script, or:
## var cam: CameraShakeTrauma = $Camera2D
##
## # On explosion:
## cam.add_trauma(0.5)
##
## # On heavy hit:
## cam.add_trauma(0.8)
##
## # Trauma decays naturally over ~1 second
# camera_state_machine.gd
# Managing transitions between multiple camera states
extends Node
# This pattern uses a central manager to handle transitions
# between 'Follow', 'Static', and 'Cinematic' camera states.
enum State { FOLLOW, STATIC, CINEMATIC }
var current_state: State = State.FOLLOW
@onready var main_camera: Camera2D = get_viewport().get_camera_2d()
func transition_to_static(pos: Vector2, duration: float = 1.0) -> void:
current_state = State.STATIC
var tween = create_tween()
# Disable smoothing during manual transition to take full control
main_camera.position_smoothing_enabled = false
tween.tween_property(main_camera, "global_position", pos, duration)\
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_IN_OUT)
await tween.finished
# Re-enable if needed
func set_follow_target(node: Node2D) -> void:
current_state = State.FOLLOW
# Use RemoteTransform2D on target to drive camera position
# for perfectly decoupled logic.
# cinematic_framing_logic.gd
# Implementing Rule of Thirds and Lead Room in code [31]
extends Camera2D
@export var target: Node2D
@export var look_ahead_factor: float = 0.2
@export var vertical_offset_ratio: float = -0.1 # Move up for Rule of Thirds
func _process(delta: float) -> void:
if not target: return
# Rule of Thirds: Offset the target slightly above center
var v_offset = get_viewport_rect().size.y * vertical_offset_ratio
# Lead Room: Shift camera in direction of target velocity
var velocity = Vector2.ZERO
if "velocity" in target:
velocity = target.velocity
var lead_offset = velocity * look_ahead_factor
var goal_pos = target.global_position + lead_offset + Vector2(0, v_offset)
# Smoothly interpolate to the framed goal
global_position = global_position.lerp(goal_pos, 5.0 * delta)
# deadzone_drag_margins.gd
# Platformer-style deadzone management in code [267]
extends Camera2D
func _ready() -> void:
# Enables the 'drag' margins that define a central deadzone
drag_horizontal_enabled = true
drag_vertical_enabled = true
# Margin 0.2 means the player must move 20% from center
# before the camera starts following.
drag_left_margin = 0.2
drag_right_margin = 0.2
drag_top_margin = 0.4
drag_bottom_margin = 0.1
# Visualizes the deadzone in the editor
editor_draw_drag_margin = true
# first_person_sway.gd
# Procedural head-bob and weapon sway for FPS games [212]
extends Camera3D
@export var bob_freq: float = 2.0
@export var bob_amp: float = 0.08
var _time: float = 0.0
func _process(delta: float) -> void:
var velocity = get_parent().velocity if get_parent() is CharacterBody3D else Vector3.ZERO
var horizontal_vel = Vector2(velocity.x, velocity.z).length()
if horizontal_vel > 0.1:
_time += delta * horizontal_vel
# 8-figure head bob
var bob = Vector3.ZERO
bob.y = sin(_time * bob_freq) * bob_amp
bob.x = cos(_time * bob_freq * 0.5) * bob_amp
transform.origin = bob
else:
_time = 0
transform.origin = transform.origin.lerp(Vector3.ZERO, delta * 5.0)
# skills/camera-systems/code/juice_camera.gd
extends Camera2D
## Juice Camera Expert Pattern
## Combines Trauma-based Simplex Noise shake with Velocity Lead Room.
@export_group("Trauma Settings")
@export var decay: float = 0.8 # How quickly trauma drops
@export var max_offset: Vector2 = Vector2(100, 75)
@export var max_roll: float = 0.1
@export var noise: FastNoiseLite = FastNoiseLite.new()
@export_group("Lead Room Settings")
@export var lead_distance: float = 200.0
@export var lead_speed: float = 5.0
var trauma: float = 0.0 # Current "stress" level (0 to 1)
var trauma_power: int = 2 # Trauma is squared for feel
var _noise_y: int = 0
func _ready() -> void:
randomize()
noise.seed = randi()
noise.frequency = 0.5
func _process(delta: float) -> void:
# 1. Decay trauma
trauma = max(trauma - decay * delta, 0.0)
# 2. Apply shake
if trauma > 0:
_apply_shake()
# 3. Handle Lead Room (Logic should usually be in a separate controller,
# but integrated here for reference)
var target_vel = Vector2.ZERO # In practice, get from Player.velocity
var target_offset = target_vel.normalized() * lead_distance
offset = offset.lerp(target_offset, lead_speed * delta)
func add_trauma(amount: float) -> void:
trauma = min(trauma + amount, 1.0)
func _apply_shake() -> void:
var amount = pow(trauma, trauma_power)
_noise_y += 1
rotation = max_roll * amount * noise.get_noise_2d(noise.seed, _noise_y)
offset.x = max_offset.x * amount * noise.get_noise_2d(noise.seed * 2, _noise_y)
offset.y = max_offset.y * amount * noise.get_noise_2d(noise.seed * 3, _noise_y)
## EXPERT NOTE:
## Noise shake is superior to Random shake because it produces 'smooth' jitter
## that replicates handheld camera weight.
# minimap_viewport_manager.gd
# Setting up 2D/3D Mini-maps using SubViewports [156]
extends SubViewportContainer
# EXPERT NOTE: SubViewports are expensive. Use a low render_target_update_mode
# for UI elements that don't need 60FPS updates (like world maps).
@onready var minimap_cam: Camera2D = $SubViewport/Camera2D
@export var player: Node2D
func _ready() -> void:
# Optimization: Only update the minimap if the player moves significantly
$SubViewport.render_target_update_mode = SubViewport.UPDATE_WHEN_VISIBLE
func _process(_delta: float) -> void:
if player:
# Mini-map follows player but ignores rotation
minimap_cam.global_position = player.global_position
# skills/camera-systems/code/phantom_decoupling.gd
extends Node2D
## Phantom Camera Decoupling Pattern
## Separates 'Where we look' from 'What we follow'.
@export var target_node: Node2D
@export var smoothing: float = 0.1 # Weight (0 to 1)
var _logical_position: Vector2
func _physics_process(_delta: float) -> void:
if not target_node: return
# 1. Update Logical Position
# This position can be influenced by secondary 'weight' sources (enemies, mouse, interest points)
_logical_position = target_node.global_position
# 2. Apply Logical Position to Camera (indirectly)
# The actual Camera2D should follow this node, not the Player directly.
global_position = global_position.lerp(_logical_position, smoothing)
## WHY THIS WAY?
## By following a 'Phantom' node instead of the Player, you can perform
## cinematic offsets, lock the camera to an Area2D bounds, or shift focus
## to an explosion without detaching the player's controls from their node.
# remote_transform_decoupling.gd
# Decoupling Camera from Player hierarchy using RemoteTransform2D [30]
extends Node2D
# EXPERT NOTE: Avoid parenting the Camera directly to the Player.
# Using RemoteTransform2D prevents player rotation/scale from
# affecting the camera while keeping position sync.
@onready var remote: RemoteTransform2D = RemoteTransform2D.new()
@export var camera: Camera2D
func _ready() -> void:
add_child(remote)
remote.remote_path = camera.get_path()
# Configure what to sync
remote.update_position = true
remote.update_rotation = false # Camera stays upright
remote.update_scale = false # Camera stays at 1:1
# split_screen_setup.gd
# Managing dynamic split-screen viewports efficiently [146]
extends HBoxContainer
# Scene Structure:
# HBoxContainer
# ├─ SubViewportContainer (Player 1)
# │ └─ SubViewport
# │ └─ Camera2D
# └─ SubViewportContainer (Player 2)
# └─ SubViewport
# └─ Camera2D
func set_split_ratio(ratio: float) -> void:
# Custom weight management for asymmetric split-screen
var p1 = get_child(0) as Control
var p2 = get_child(1) as Control
p1.size_flags_stretch_ratio = ratio
p2.size_flags_stretch_ratio = 1.0 - ratio
func _ready() -> void:
# Ensure audio listeners are balanced
get_child(0).get_node("SubViewport").audio_listener_enable_2d = true
get_child(1).get_node("SubViewport").audio_listener_enable_2d = false
# spring_lerp_camera_3d.gd
# Advanced 3D camera follow using Spring interpolation [169]
extends Camera3D
@export var target: Node3D
@export var offset: Vector3 = Vector3(0, 5, 10)
@export var spring_stiffness: float = 15.0
func _physics_process(delta: float) -> void:
if not target: return
var target_pos = target.global_position + offset
# Spring-based follow prevents the 'elastic' feel of simple lerp
# and reduces visual stutter at high speeds.
global_position = global_position.lerp(target_pos, delta * spring_stiffness)
look_at(target.global_position)
# zoom_damping_controller.gd
# Smooth, non-linear zoom control for tactical overview
extends Camera2D
@export var min_zoom: float = 0.5
@export var max_zoom: float = 2.0
@export var zoom_speed: float = 10.0
var target_zoom: Vector2 = Vector2.ONE
func _input(event: InputEvent) -> void:
if event is InputEventMouseButton:
if event.button_index == MOUSE_BUTTON_WHEEL_UP:
target_zoom = (target_zoom - Vector2(0.1, 0.1)).max(Vector2(min_zoom, min_zoom))
elif event.button_index == MOUSE_BUTTON_WHEEL_DOWN:
target_zoom = (target_zoom + Vector2(0.1, 0.1)).min(Vector2(max_zoom, max_zoom))
func _process(delta: float) -> void:
# Exponential lerp for zoom feels smoother than linear
zoom = zoom.lerp(target_zoom, zoom_speed * delta)