readonly x: numberWorld x at the center of the viewport. Read-only, and not merely by convention — see
the module header for why the field is unavailable rather than discouraged.
readonly y: numberWorld y at the center of the viewport. See Camera.x.
readonly zoom: numberWorld pixels per CSS pixel. Moved only by Camera.zoomAt, so the pointer anchor
cannot be skipped.
readonly viewW: numberViewport width in CSS pixels — never device pixels, never a canvas.
readonly viewH: numberViewport height in CSS pixels. See Camera.viewW.
readonly bounds: Readonly<Rect>The reachable world rectangle. The camera's own copy; mutating it does nothing until
Camera.setBounds is called, which is the honest half of that trade.
readonly minZoom: numberThe zoom-out limit in force — CameraOptions.minZoom, or the default 0.5, or whatever
Camera.setZoomLimits last set.
It exists so that nobody has to keep a second copy. A settings panel that draws the zoom
slider needs the range the slider is allowed to span; a "zoom out" button needs to know
whether it should be disabled; a save file needs to record the policy it was played
under. Each of those, given no reader, keeps its own copy of a number this object already
holds — and the copies drift the first time anything else moves the limits.
readonly maxZoom: numberThe zoom-in limit in force. See Camera.minZoom.
readonly keepVisible: numberThe fraction of the viewport that must still show Camera.bounds after any
gesture — CameraOptions.keepVisible, or the default 0.35, or whatever
Camera.setKeepVisible last set. Read it to show the clamp you are subject to:
it is the one number that explains why a pan stopped where it did.
resize(viewW: number, viewH: number): voidRe-clamps against a new viewport size. Call it on every viewport change including an
orientation flip: the clamp depends on the half-viewport in world units, so a camera that
is not told the window shrank keeps letting the player look outside the map.
ThrowsRangeError if either dimension is not a finite number greater than zero.
setBounds(bounds: Readonly<Rect>): voidReplace the reachable rectangle — the island grew, the level loaded — and re-clamp at
once, so no frame is ever drawn against bounds the camera has not been checked against.
setZoomLimits(minZoom: number, maxZoom: number): voidReplace the zoom limits — a settings change, a difficulty tier, an accessibility option —
and re-clamp at once.
Why this exists beside a zoom that is deliberately unassignable. They are not the
same kind of thing. zoom is a position: it moves under a pointer, and the rule
zoomAt enforces is that no path may move it without deciding what stays put — origin-
anchored zoom is the single most common reason a tile-game camera feels broken, and a
set zoom accessor is precisely a path that decides nothing. The limits are policy:
they say what the player is allowed to do, they are set by configuration rather than by a
gesture, and this method does decide what stays put — the viewport center, exactly as
fitBounds decides on the rectangle's center. The invariant was never "zoom is
immutable"; it was "nothing changes zoom without naming an anchor", and this names one.
The escape it opens is real and worth stating rather than hiding: setZoomLimits(2, 2)
does force zoom to 2 with no pointer involved. It also freezes the zoom permanently,
which is a loud symptom and useless as a way to sneak a gesture through. The rule makes
the common mistake unrepresentable; it is not a security boundary and was never sold as
one.
If the current zoom falls outside the new range it is clamped on the spot, and that can
move the view. Raising minZoom past the current zoom pushes the camera in; lowering
maxZoom below it pulls the camera out; and either can then move x/y, because the
half-viewport in world units changed and the Camera.keepVisible clamp is computed
from it. So a minZoom slider dragged live rescales the world under the finger. That is
the correct behavior — the alternative is a camera sitting outside its own declared limits
until the player's next wheel notch snaps it, at a moment they did not cause and cannot
connect to anything — but a panel that does not want the view moving mid-drag should
commit on release. It is the same rule CameraOptions.zoom already applies to a stale
saved zoom at construction, applied for the rest of the camera's life.
Both limits are taken together, not one at a time, because minZoom <= maxZoom is a
relation between them: a single-field setter would have to either reject the halfway state
of a slider drag that crosses the other limit, or silently reorder the pair. Taking both
makes the invariant a thing the caller states and this method checks.
ThrowsRangeError if either limit is not a finite number greater than zero, or if
minZoom > maxZoom. The message shape is createCamera's, with this method's name.
setKeepVisible(keepVisible: number): voidReplace the fraction of the viewport that must keep showing Camera.bounds, and
re-clamp at once.
Re-clamping is the point, and it means this call can move the camera. Raising the
fraction while the player is near a map edge pulls them back toward it in the same
statement — with nothing in flight, no animation, no next frame required. Deferring
instead would leave a camera showing a view its own policy forbids until the next pan,
which is the failure Camera.setBounds already refuses for the same reason.
ThrowsRangeError if keepVisible is outside [0, 1] — NaN included, which is what an
unparsed slider value arrives as and which would otherwise turn the clamp into NaN on
both axes and put the camera nowhere.
toScreenX(wx: number): numberworld → screen x. Takes wx alone, because screen x depends on world x alone.
This is the form that writes into a Float64Array: pen[i] = cam.toScreenX(wx);
pen[i + 1] = cam.toScreenY(wy); — no intermediate object at any point. A caller
projecting the eight corners of a box projects four x values, not eight, which is the
whole reason the two axes are separate functions.
toScreenY(wy: number): numberworld → screen y. See Camera.toScreenX.
toScreen(wx: number, wy: number, out: Vec2): Vec2world → screen, both axes, into a caller-owned Vec2. Returns out so calls chain.
normalizedX(wx: number): numberWhere a world x sits across the viewport: -1 at the left edge, 0 at the center,
+1 at the right, continuing past them rather than clamping.
The third member of the projection family, and it exists because @latticekit/audio needs
it and may not depend on this package: a sound's stereo pan is the normalizedX of the
thing that made it. Unclamped on purpose — how far a pan may go is a mixing policy
(audio caps at ±0.6, because full-width panning is unpleasant on headphones and
inaudible on a phone speaker) and a policy does not belong in a projection.
There is no normalizedY. Stereo has one axis.
toWorldX(sx: number): numberscreen → world x. The exact inverse of Camera.toScreenX.
toWorldY(sy: number): numberscreen → world y.
toWorld(sx: number, sy: number, out: Vec2): Vec2screen → world, both axes, into a caller-owned Vec2.
panByScreen(dxScreen: number, dyScreen: number): voidPan by a screen-space delta — a drag.
Divided by zoom internally so the world tracks the finger exactly at any scale.
Multiplying instead of dividing is the bug where a zoomed-in map slides at a crawl and a
zoomed-out one bolts, and it looks like a tuning problem rather than a sign error.
zoomAt(factor: number, sx: number, sy: number): voidZoom, keeping the world point under (sx, sy) pinned to that screen pixel.
centerOn(wx: number, wy: number): voidPut a world point at the center of the viewport immediately, then clamp.
fitBounds(worldRect: Readonly<Rect>, marginPx?: number): voidFrame a world rectangle: the zoom that makes it fit, then the center that shows it.
The first thing every game does, and the one thing Camera.zoomAt cannot do.
zoomAt takes a factor and a required anchor because that is what a wheel notch and a
pinch are. Framing a generated world is the other problem: the caller knows the rectangle
it wants on screen and does not know — must not have to compute — the ratio between that
and the zoom it happens to be at. Written against zoomAt it comes out as
zoomAt(want / camera.zoom, viewW / 2, viewH / 2), and that division is this method's
absence rather than anyone's style.
Content height enters through the rectangle, and there is nowhere else it can. A
rectangle is the whole of what this method knows, so a caller that frames
tileBounds(0, 0, w, d, 0, out) frames the ground plane and a 440-pixel summit lands off
the top of the screen on the first frame. Pass the map's tallest elevation as
tileBounds's heightPx — it extends minY upward, which is exactly the extra span
the fit has to pay for — or union in the boxes of whatever stands on the map. There is
deliberately no separate height parameter: two ways to say the same thing is how one of
them gets passed twice.
The final center is the rectangle's center after the bounds clamp, so on a map smaller
than keepVisible demands the two differ, and the clamp wins. Frame first, then read
Camera.x if you need to know where it settled.
Zoom is written directly here, and that is not a hole in the anchoring rule. zoomAt
exists so that a gesture cannot skip pinning the world point under the pointer; a fit has
no pointer and pins the rectangle's center instead. The invariant is "no path changes zoom
without deciding what stays put", and this path decides.
ThrowsRangeError if any edge is not finite — including the rectMakeEmpty state, which
is what an accumulator loop that unioned nothing leaves behind — if the rectangle is
inverted, or if marginPx is negative or not finite.
centerOnTile(gx: number, gy: number): voidPut a tile at the center. The form callers actually want after loading a save, and the
one that stops every game writing gridToWorld into a scratch vector to do it.
clamp(): voidRe-apply the clamp. Every mutator calls it already; it is exposed for a caller who
changed the bounds rectangle by some other route, and for tests asserting idempotence —
clamp(); clamp() must be a no-op, and the version that oscillates between two positions
is the one that fed min > max to a two-sided clamp.
isVisible(minX: number, minY: number, maxX: number, maxY: number): booleanIs this world box worth drawing? A cheap AABB reject, generous by one tile on each axis
so that geometry poking outside its declared box does not flicker at the edge of the
screen.
visibleTileBounds(out: TileRange, marginTiles?: number): TileRangeThe conservative grid rectangle covering the viewport — the terrain loop's bounds.
Computed by projecting the four screen corners into grid space and taking the
min/max, because the visible region is a diamond in grid space, not a rectangle. A loop
derived from a grid-space rectangle silently misses the two side corners of the screen
and leaves triangular holes of unpainted ground. The returned range over-covers by
roughly 2×, and that is the correct trade against a per-tile diamond intersection test.
visibleWorldBounds(out: Rect, marginPx?: number): RectThe world rectangle covering the viewport, for culling anything not on the tile
lattice — a backdrop gradient, a light pool, a cached scenery chunk.
The Rect-shaped counterpart to Camera.isVisible: that one asks about a box you
have, this one hands over the box to test against.