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