168 lines
5.4 KiB
GDScript
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
|