Files
gamejamgame/scripts/couch_camera.gd
T

168 lines
5.4 KiB
GDScript

class_name CouchCamera
extends Camera3D
## Shared-screen camera: keeps every target framed, and shakes.
##
## The camera holds the orientation it was authored with in the scene. Only its
## distance along that view direction changes, so the shot keeps the angle and
## the lens it was composed with, and the players are what moves in frame.
@export var targets: Array[Node3D] = []
## Padding kept outside the outermost target, in metres. Not pixels: everything
## this camera measures is world space.
@export var margin := Vector2(2.0, 1.5)
## How close in and how far back the camera may pull to fit the group, in metres.
@export var min_distance := 4.0
@export var max_distance := 40.0
## Follow and zoom response, as exponential rates. Higher is snappier.
@export var fspeed := 6.0
@export var zspeed := 4.0
@export var auto_find := true
## Shifts the framed point, in metres, for composition. Zero centres the group.
@export var framing_offset := Vector3.ZERO
## Rope to take the sustained buzz from. Anything with a tension_ratio() -> float
## method will do.
@export var tether_path : NodePath
# Snap punch: an impulse that decays fast and sharp. Offsets are metres.
@export var punch_decay := 2.6
@export var punch_offset := 0.8
@export var punch_roll := 0.05
# Tension buzz: sustained, tracks the current tension, ramps in.
@export var tension_offset := 0.15
@export var tension_roll := 0.01
@export var tension_ramp := 12.0
var trauma := 0.0
var tension_shake := 0.0
var _tension_target := 0.0
var _tether : Node
# Authored view direction and roll. Framing moves the camera along _view_dir and
# nothing else, so the authored angle survives.
var _view_dir := Vector3.FORWARD
var _rest_roll := 0.0
# Smoothed framing. Seeded from the first solve rather than from the authored
# transform, so the level opens on the right shot instead of easing into it.
var _center := Vector3.ZERO
var _distance := 0.0
var _framed := false
func _ready() -> void:
make_current()
_view_dir = -global_transform.basis.z
_rest_roll = rotation.z
_tether = get_node_or_null(tether_path)
# Deferred: the camera is an earlier sibling than the players, so its _ready
# runs first, and joining the "players" group is something their _ready does.
# Searching the group here finds nothing at all.
_find_targets.call_deferred()
func _find_targets() -> void:
if not auto_find or not targets.is_empty():
return
for n in get_tree().get_nodes_in_group("players"):
if n is Node3D:
targets.append(n)
# Solve once right away, or the first frame is drawn from the authored
# transform and the shot pops as soon as _physics_process catches up.
_frame_targets(0.0)
## Add an impulse shake, 0-1. For hits, landings, deaths — anything that is a
## moment rather than a duration. Callers drive this; nothing does by default.
func add_trauma(amount: float) -> void:
trauma = clampf(trauma + amount, 0.0, 1.0)
## Set the sustained shake level, 0-1. Driven from the tether every frame when
## tether_path is set.
func set_tension(amount: float) -> void:
_tension_target = clampf(amount, 0.0, 1.0)
func _physics_process(d: float) -> void:
if _tether != null and _tether.has_method("tension_ratio"):
set_tension(_tether.tension_ratio())
_frame_targets(d)
_apply_shake(d)
func _frame_targets(d: float) -> void:
var live : Array[Vector3] = []
for t in targets:
if is_instance_valid(t):
live.append(t.global_position)
if live.is_empty():
return
var center := Vector3.ZERO
for p in live:
center += p
center = center / float(live.size()) + framing_offset
# Measure the group along the camera's own screen axes, so an authored tilt
# is accounted for instead of assuming the group spreads along world X-Y.
var right := global_transform.basis.x
var up := global_transform.basis.y
var half_w := margin.x
var half_h := margin.y
for p in live:
var v := p - center
half_w = maxf(half_w, absf(v.dot(right)) + margin.x)
half_h = maxf(half_h, absf(v.dot(up)) + margin.y)
# Solve for distance, not fov. fov is the authored look of the shot, and
# zooming by changing it warps the perspective as the players move. Godot's
# default keep_aspect is KEEP_HEIGHT, so fov is the vertical angle and the
# horizontal one follows from the viewport aspect.
var half_fov := tan(deg_to_rad(fov) * 0.5)
var vp := get_viewport().get_visible_rect().size
var aspect := vp.x / maxf(vp.y, 1.0)
var distance := clampf(
maxf(half_h / half_fov, half_w / (half_fov * aspect)),
min_distance,
max_distance
)
if _framed:
_center = _center.lerp(center, 1.0 - exp(-fspeed * d))
_distance = lerpf(_distance, distance, 1.0 - exp(-zspeed * d))
else:
_center = center
_distance = distance
_framed = true
global_position = _center - _view_dir * _distance
func _apply_shake(d: float) -> void:
var off := Vector2.ZERO
var roll := 0.0
tension_shake = move_toward(tension_shake, _tension_target, tension_ramp * d)
if tension_shake > 0.001:
var ts := tension_shake * tension_shake
off += Vector2(randf_range(-1, 1), randf_range(-1, 1)) * tension_offset * ts
roll += randf_range(-1, 1) * tension_roll * ts
if trauma > 0.0:
trauma = maxf(trauma - punch_decay * d, 0.0)
var s := trauma * trauma
off += Vector2(randf_range(-1, 1), randf_range(-1, 1)) * punch_offset * s
roll += randf_range(-1, 1) * punch_roll * s
# h_offset/v_offset shift the frustum, in metres, so they shake the image
# without disturbing the framing solved above.
h_offset = off.x
v_offset = off.y
rotation.z = _rest_roll + roll