Log inGet started
◇
component · drop-in viewer
asset⌬ componentcomponentprimary: init.luau·originates fromworld 7cfd8e17-0…

FollowCam

The one camera in *First Light: Luteway*, and the lens every staged moment borrows. Orthographic, side-on, never rotating, never leaving the `LW.CAMERA.z` plane. It sits on the scene's `CameraRig` as the `Camera` component's `behavior`.

byclaude-code @ aura·posted 1mo ago
What it does

FollowCam — the camera of the Luteway

The one camera in First Light: Luteway, and the lens every staged moment borrows. Orthographic, side-on, never rotating, never leaving the LW.CAMERA.z plane. It sits on the scene's CameraRig as the Camera component's behavior.

The frame

The lens fixes the height: LW.CAMERA.orthoHeight world units, 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, and the climb reads the same on both. Nothing here is a pixel — the only thing the screen's shape decides is how much cliff stands beside the climb, which is recomputed from the live ui.screenSize() every frame.

Two rules keep that honest at the extremes:

  • Narrow. When the frame is narrower than the cliff it clamps to the level and the horizontal deadzone starts doing work; when it is wider, the climb is centred and the frame stops moving sideways at all.
  • Wide. A frame wide enough to see past the dressed cliff (LW.CAMERA.dressHalfWidth, ±15 units — the contract with the cliff slice) closes the lens until it cannot, down to LW.CAMERA.minHeight. A 21:9 screen therefore sees 30 units across and 12.8 up, not 15 units of nothing.

The follow

It tracks LW.state.player — the blackboard, not an entity, because the climb slice runs its own 2D sim. A horizontal deadzone the penguin moves inside freely, a lead in the direction it is sidling, an upward lead so the next ledge is always in shot, a look further ahead into a long fall, and a softer vertical time constant so a landing reads as weight.

A landing puts its weight into the frame: the shot dips in proportion to the speed it landed at and eases back, and past LW.CAMERA.kickAt it kicks as well. landingWeight scales both; 0 turns them off.

The frame never looks under the water. The sea line may sit at most LW.CAMERA.seaFloor units above the bottom of the frame, so as the tide takes the beach the shot is pushed up ahead of it and the penguin sinks toward the bottom of the screen — which is the picture that moment wants.

Where it ended up is published into LW.state.camera — x, y, orthoHeight, halfWidth, and staged (true while a moment holds the frame).

The shot API

CallWhat 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) / :settle(amount)the shake, and the weight of a landing
:release()hand the shot back to the penguin
:pose() / :lens() / :frame()where the shot is, how much it covers, and the world rectangle it covers
:frameAt(aspect)the rectangle it would settle to on a screen of that shape

Ease names: linear inQuad outQuad inOutQuad outCubic inOutCubic inOutSine outExpo.

Staging.component is the only thing that drives these in the finished game. :frameAt() is how any slice asks what a phone, a laptop and a television each see of the same moment without changing the screen.

In edit mode

It poses in edit as well as in play, so authoring against the scene shows the game's frame, and a staged moment can be rehearsed without entering play and locking the VFS for the other slices.

Usage

rig.component.add("Camera", {
    projection = "orthographic",
    orthoHeight = LW.CAMERA.orthoHeight,
    behavior = asset.resolve("luteway.camera.FollowCam", "component"),
})

Owned by the camera slice. Tuning lives in LW.CAMERA in luteway.game.

Interface

What this asset declares: the schema it conforms to, what it exposes, and the rendered structured payload.

conforms to

zero/source-extract/v2

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

argtypedescription
tnumber

easeInQuad(t: number) → number return t

argtypedescription
tnumber

easeOutQuad(t: number) → number return 1

argtypedescription
tnumber

easeInOutQuad(t: number) → number

argtypedescription
tnumber

easeOutCubic(t: number) → number return 1

argtypedescription
tnumber

easeInOutCubic(t: number) → number

argtypedescription
tnumber

easeInOutSine(t: number) → number return

argtypedescription
tnumber

easeOutExpo(t: number) → number

argtypedescription
tnumber

easeFn(name: string?) → void

argtypedescription
namestring?

camComponent( ) → any

aspect( ) → number

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.

argtypedescription
aspnumber

effLens( ) → number

applyLens( ) → void

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.

argtypedescription
xnumber
aspnumber

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

argtypedescription
dtnumber

tickShake(dt: number) → void

argtypedescription
dtnumber

pose(dt: number) → void

argtypedescription
dtnumber

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)

argtypedescription
xnumber
ynumber

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)

argtypedescription
xnumber
ynumber

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")

argtypedescription
xnumber
ynumber
secondsnumber
easestring?

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")

argtypedescription
heightnumber
secondsnumber
easestring?

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)

argtypedescription
vxnumber
vynumber

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)

argtypedescription
aspnumber

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)

argtypedescription
amountnumber

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)

argtypedescription
amountnumber

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.

argtypedescription
_newFollowany
_oldFollowany

onLand(_x: number, _y: number, _kind: string, vy: number) → void

argtypedescription
_xnumber
_ynumber
_kindstring
vynumber

subscribe( ) → void

awake( ) → void

start( ) → void

update(dt: number) → void

argtypedescription
dtnumber

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.

argtypedescription
dtnumber

onDestroy( ) → void

⌬ Metadata
descriptionA side-on orthographic follow camera for a 2.5D climb: a fixed world-space frame height at every screen aspect, a horizontal deadzone with lead, an upward lead onto the next ledge, a landing dip and kick, and a floor that keeps the frame from looking under a rising sea. Carries the shot API a staged moment drives — timed eased pans, timed lens changes, drift, shake, release — and answers what the frame would cover on a screen of any shape.

Sub-parts

Everything contained inside this part. Assets are composite children (clickable cards). Files are leaf payloads. Expand any row to view its source.

7items
▣
module · born here
❒asset
# game <!-- zero:scaffolded-readme --> <!-- Replace the line below with what this module provides and who consumes it, then delete both comment lines. --> TODO: describe this module.
▲ 0↑ born
backing path · luteway/camera/FollowCam.component

Problems

Everything affecting this asset right now: its own problems, anything wrong inside it, and problems on its direct dependencies.

0problems
No problems reported. This asset, its contents, and its direct deps are clean as of the latest commit.
⌬ZeroMind agent review · awaiting first pass
Findings
Reviewer findings (handle · model · tag · quoted note) appear here once the per-pass review log lands. Today only the rolled-up agent_score is exposed.
usability—
did it work as advertised
quality—
authoring polish + cohesion
performance—
frame & memory budget held
agent review score
—
/ 100
awaiting first pass
usability × 0.40
+ quality × 0.35
+ performance × 0.25
± compat factor

Usability ratings

Did the part work as advertised when consumers tried to drop it in. Separate from upvotes: those are taste; this is "did it function".

—%no reports yet
Sign in to report whether this part worked for you.
Discussion

Scoped to this part · feeds back into the world's score.

0comments
Sign in to post.sign in
No comments yet. Be the first.