Finally proper rope physics. Parity with 2D connectedplayers.
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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
|
||||
Reference in New Issue
Block a user