FollowCam — the camera of the Luteway, and the lens every staged moment
borrows. Owned by the CAMERA slice.
Orthographic, side-on, never rotating, never leaving the `LW.CAMERA.z`
plane; it looks straight down -Z at the flat play plane. The frame covers
`LW.CAMERA.orthoHeight` world units VERTICALLY at every aspect, so a phone
held upright sees a narrow tall slice of cliff and a wide monitor sees the
same height with more cliff either side. Nothing here is a pixel: the only
thing the screen's shape decides is how much cliff sits either side of the
climb, which is why the horizontal clamp is computed from the live aspect
every frame.
## In play
It follows `LW.state.player` — the blackboard, not an entity, because the
climb slice runs its own 2D sim. There is a horizontal deadzone the penguin
moves inside without the frame answering, a lead in the direction of travel,
an upward lead so the next ledge is always in shot, a softer vertical time
constant so a landing reads as weight, a dip and a kick on a hard landing,
and a floor at the sea so the frame never looks under the water. Where it
ends up is published into `LW.state.camera`.
## When a moment takes it
| Call | What it does |
|---|---|
| `:panTo(x, y, seconds, ease)` | a TIMED move along an eased curve; `:isPanning()` says when it arrives |
| `:zoomTo(height, seconds, ease)` | a TIMED change of lens. 16 is the playing frame, 9.5 a push, 26 the cliff opening out |
| `:driveTo(x, y)` | the untimed, damped drive |
| `:snapTo(x, y)` | a cut |
| `:setDrift(vx, vy)` | a slow creep added to a driven shot — a held frame that drifts is alive |
| `:kick(amount)` | the shake: a hard landing, a wave |
| `:release()` | hand the shot back to the penguin, cancelling pan and drift |
| `:pose()` | where the shot is, with the shake taken back out |
| `:lens()` | the frame's current world-space height |
Ease names: `linear` `inQuad` `outQuad` `inOutQuad` `outCubic` `inOutCubic`
`inOutSine` `outExpo`.
It poses in EDIT mode too, so a staged moment can be rehearsed without
entering play and locking the VFS for the other slices.
Usage (the scene's CameraRig):
rig.component.add("Camera", {
projection = "orthographic", orthoHeight = LW.CAMERA.orthoHeight,
behavior = asset.resolve("luteway.camera.FollowCam", "component"),
})
easeLinear(t: number) → number return t end
| arg | type | description |
|---|
| t | number | |
easeInQuad(t: number) → number return t
| arg | type | description |
|---|
| t | number | |
easeOutQuad(t: number) → number return 1
| arg | type | description |
|---|
| t | number | |
easeInOutQuad(t: number) → number
| arg | type | description |
|---|
| t | number | |
easeOutCubic(t: number) → number return 1
| arg | type | description |
|---|
| t | number | |
easeInOutCubic(t: number) → number
| arg | type | description |
|---|
| t | number | |
easeInOutSine(t: number) → number return
| arg | type | description |
|---|
| t | number | |
easeOutExpo(t: number) → number
| arg | type | description |
|---|
| t | number | |
easeFn(name: string?) → void
| arg | type | description |
|---|
| name | string? | |
lensAt(asp: number) → number
The lens the frame is actually DRAWN with. `lensH` is what the shot asked
for; on a screen wide enough that the asked-for lens would show past the
dressed cliff, the frame closes until it does not, so no shape of screen
ever looks into undressed space. It only ever closes, never opens, and it
stops at `LW.CAMERA.minHeight` so the climb stays readable on any screen.
| arg | type | description |
|---|
| asp | number | |
visibleHalfWidth( ) → number
clampX(x: number, asp: number) → number
Where the frame's horizontal centre is allowed to sit at a given screen
aspect. The lens fixes the HEIGHT, so the aspect decides only how much cliff
stands either side of the climb — and whether the frame has to clamp to the
level at all.
| arg | type | description |
|---|
| x | number | |
| asp | number | |
followTarget( ) → void
Where the shot wants to be when nothing has taken it. Everything here is
proportional to the lens, so the same rules hold whatever the screen shape.
tickZoom(dt: number) → void
| arg | type | description |
|---|
| dt | number | |
tickShake(dt: number) → void
| arg | type | description |
|---|
| dt | number | |
pose(dt: number) → void
| arg | type | description |
|---|
| dt | number | |
snapTo(x: number, y: number) → void
Cut the shot to a world point with no easing.
@param x number World x to sit at.
@param y number World y to sit at.
@example entity.find("CameraRig").component.get("FollowCam"):snapTo(0, 40)
| arg | type | description |
|---|
| x | number | |
| y | number | |
driveTo(x: number, y: number) → void
Take the shot away from the penguin and drive it by hand, easing in at the follow's own rate.
@param x number World x to drive toward.
@param y number World y to drive toward.
@example entity.find("CameraRig").component.get("FollowCam"):driveTo(0, 12)
| arg | type | description |
|---|
| x | number | |
| y | number | |
panTo(x: number, y: number, seconds: number, ease: string?) → void
Move the shot to a point over a stated number of seconds along an eased curve — the opening crane, the push onto the penguin, the pull back as the sea closes. Ask :isPanning() when it has arrived.
@param x number World x to arrive at.
@param y number World y to arrive at.
@param seconds number How long the move takes. 0 arrives immediately.
@param ease string? Curve name — linear, inQuad, outQuad, inOutQuad, outCubic, inOutCubic, inOutSine, outExpo. Defaults to inOutQuad.
@example entity.find("CameraRig").component.get("FollowCam"):panTo(0, 4, 4.5, "inOutSine")
| arg | type | description |
|---|
| x | number | |
| y | number | |
| seconds | number | |
| ease | string? | |
zoomTo(height: number, seconds: number, ease: string?) → void
Change how much world the frame covers vertically, over a stated number of seconds. Smaller is closer — 16 is the playing frame, 9.5 a push onto the penguin, 26 the whole cliff.
@param height number World units the frame covers vertically at the end of the move.
@param seconds number How long the change takes. 0 applies it at once.
@param ease string? Curve name, as :panTo. Defaults to inOutQuad.
@example entity.find("CameraRig").component.get("FollowCam"):zoomTo(9.5, 1.2, "outCubic")
| arg | type | description |
|---|
| height | number | |
| seconds | number | |
| ease | string? | |
setDrift(vx: number, vy: number) → void
Add a slow constant creep to a driven shot, in world units per second. A held frame that drifts is alive; one that is nailed down is a still. Cleared by :release().
@param vx number Horizontal creep, world units per second.
@param vy number Vertical creep, world units per second.
@example entity.find("CameraRig").component.get("FollowCam"):setDrift(0, 0.22)
| arg | type | description |
|---|
| vx | number | |
| vy | number | |
isPanning( ) → boolean
True while a :panTo is still travelling.
@example if not cam:isPanning() then nextBeat() end
isStaged( ) → boolean
True while any staged shot holds the frame — a pan, a drive, or a held beat.
@example if not cam:isStaged() then ... end
pose( ) → void
Where the shot is right now, in world units, with the shake taken back out.
@example local x, y = cam:pose()
lens( ) → number
How much world the frame covers vertically right now — the lens as DRAWN, after the wide-screen close.
@example local h = cam:lens()
frame( ) → void
The world-space rectangle the frame covers right now — x0, y0, x1, y1. What is on screen, in world units.
@example local x0, y0, x1, y1 = cam:frame()
frameAt(asp: number) → void
The rectangle the frame would settle to at a given SCREEN ASPECT (width / height), for the penguin where it stands now — how a phone held upright, a laptop and a television each frame the same moment. The lens fixes the height, so the aspect changes only how much cliff stands either side; the penguin may sit up to `LW.CAMERA.deadX` off the centre this returns, which is the horizontal deadzone.
@param asp number Screen aspect: width divided by height.
@example local x0, y0, x1, y1 = cam:frameAt(16 / 9)
| arg | type | description |
|---|
| asp | number | |
release( ) → void
Hand the shot back to the penguin, cancelling any pan and any drift. The lens is left where it is; zoom it back yourself if a beat changed it.
@example entity.find("CameraRig").component.get("FollowCam"):release()
kick(amount: number) → void
Kick the shot — a hard landing, a wave, a rock giving way.
@param amount number How far the shot jumps, in world units.
@example entity.find("CameraRig").component.get("FollowCam"):kick(0.35)
| arg | type | description |
|---|
| amount | number | |
settle(amount: number) → void
Put the weight of a landing into the frame: it dips and eases back.
@param amount number World units the frame sinks by.
@example entity.find("CameraRig").component.get("FollowCam"):settle(0.4)
| arg | type | description |
|---|
| amount | number | |
onFollowChanged(_newFollow: any, _oldFollow: any) → void
Called by the Camera when its `follow` slot changes. This camera tracks
LW.state.player, which the climb slice writes, so a new follow target only
re-seeds the frame.
| arg | type | description |
|---|
| _newFollow | any | |
| _oldFollow | any | |
onLand(_x: number, _y: number, _kind: string, vy: number) → void
| arg | type | description |
|---|
| _x | number | |
| _y | number | |
| _kind | string | |
| vy | number | |
update(dt: number) → void
| arg | type | description |
|---|
| dt | number | |
editorUpdate(dt: number) → void
Edit mode runs no gameplay, but somebody authoring against this scene still
has to be looking at the game's frame — and a staged moment has to be
rehearsable without entering play, which locks the VFS for the other slices.
| arg | type | description |
|---|
| dt | number | |