ajhahn.de
← Theria
GDScript 86 lines
class_name MatchCamera
extends RefCounted
## The match follow-rig: a close, LoL-style camera trailing the player's hero, lifted out of
## `main.gd` to keep that file under the line cap (the same reason PlayerInput, MoveMarker, and the
## overlays were lifted). It owns the `Camera3D`, its eased target, and the free-look state — a
## minimap left-click pans the view off the hero, and the re-centre key drops it back on.
##
## It holds no sim knowledge: each tick the driver hands it the hero's field point (or signals there
## is none yet) and whether the re-centre key is down, and it eases the camera. The sim→3D mapping
## comes in as a Callable so the rig and the driver agree on one projection.

## Follow-rig geometry: height and the backward offset set the look angle (atan(height / back) ~=
## 67°) and the zoom (~950 units off the hero); eyeball tuning knobs for the windowed playtest.
const HEIGHT := 880.0
const BACK := 370.0
## How far the camera closes the gap to its target each tick (0..1) — a smooth trail rather than a
## hard 1:1 lock, so a direction change eases. At 60 Hz, 0.2/tick settles in ~0.25 s.
const LERP := 0.2

## The Camera3D itself — the driver adds it to the scene and hands it to PlayerInput for the ground
## ray. Made current so it is the view the moment the match opens.
var node: Camera3D

## The field point the camera trails. Set to the hero each tick it exists and held at its last value
## while the hero is gone (dead, pre-spawn), so the view rests on the last sighting rather than
## snapping to the arena centre. Seeded at the arena centre for the menu backdrop.
var _target: Vector2
## False until the camera has been placed once: the first placement snaps (no glide-in from the
## world origin), every one after eases toward the target by LERP.
var _ready: bool = false
## Free-look: true while the player has panned the camera off their hero with a minimap left-click,
## so `follow` holds the panned target instead of re-pinning it to the hero. Cleared by the
## re-centre key, back to following the hero.
var _free: bool = false
## The sim→3D ground projection, shared with the driver so both agree on one mapping.
var _to_world: Callable


func _init(to_world: Callable) -> void:
	_to_world = to_world
	node = Camera3D.new()
	node.far = 20000.0
	node.current = true


## Snaps the camera onto a point with no glide — the build-time placement that frames the arena
## centre for the menu backdrop before any hero exists. Call once the node is in the tree (look_at
## needs a global transform); every placement after eases.
func place(point: Vector2) -> void:
	_target = point
	_point()


## The field point the camera is framing this tick — the hero, or the free-look point. The driver
## reads it to fade the foliage standing over the framed spot.
func target() -> Vector2:
	return _target


## Pans the camera to a point chosen off the minimap and holds it there (free look) until the player
## re-centres on their hero.
func look_at_point(point: Vector2) -> void:
	_target = point
	_free = true


## Trails the hero for this tick: re-pins to its field point (held at the last while the hero is
## gone), unless free-look holds a panned point. `recenter` — the re-centre key — drops free-look
## back onto the hero. `has_focus` is false before a hero spawns or once it leaves the world.
func follow(focus: Vector2, has_focus: bool, recenter: bool) -> void:
	if _free and recenter:
		_free = false
	if not _free and has_focus:
		_target = focus
	_point()


## Eases the camera toward a pose above and behind the target, looking down at it. The first
## placement snaps; each tick after closes LERP of the remaining gap, so the view trails smoothly.
func _point() -> void:
	var ground: Vector3 = _to_world.call(_target)
	var goal := ground + Vector3(0.0, HEIGHT, BACK)
	node.position = goal if not _ready else node.position.lerp(goal, LERP)
	_ready = true
	node.look_at(ground)