# bus
<!-- 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.
# staging.camera
The one thing that moves `FightCamera`. Registered as `Bus.services.camera`.
The camera is orthographic and never rotates. Everything it does is three
numbers: where it sits on x, where it sits on y, and how much world height it
shows (3.4 at its closest, 5.6 when the fighters are at the walls).
```lua
Bus.call("camera", "follow", p1.x, p2.x) -- the fight's resting behaviour
Bus.call("camera", "focus", x, y, 1.8, 20) -- take it away for a beat
Bus.call("camera", "hold", 90, 40) -- ...and give it back on its own
Bus.call("camera", "shake", 0.4)
```
- `follow(x1, x2)` keeps both fighters in frame with a margin and widens as
they separate. Call it every frame; it is smoothed, not snapped.
- `focus(x, y, zoom, frames)` overrides `follow` for a staged beat.
`hold(frames, releaseFrames)` hands the camera back on its own, so a missed
`release` can never strand the view.
- `shake(mag)` is trauma-based: additive, decaying, and squared, so a small
value really is small. `impulse(dx, dy, tilt)` shoves the frame once and lets
it spring back — that is what makes a heavy read as a heavy rather than as
more shake.
- `lock()` / `unlock()` — while the lock is held, `follow` is ignored. The
attract sequence holds it, because a combat slice calling `follow` every
frame otherwise wins the argument against every staged shot.
- `set` / `snap` place the camera absolutely, for beats with no fighters.
The result is mirrored into `Bus.state.camera` (`x`, `y`, `zoom`, `height`,
`shakeX`, `shakeY`) every frame, so any slice can read the framing without
touching the entity.
# staging.fx
How a hit lands. Registered as `Bus.services.fx`.
```lua
Bus.call("fx", "spark", x, y, "heavy", { dir = 1 })
Bus.call("fx", "dust", x, 0, "land")
Bus.call("fx", "flash", "white", 12)
Bus.call("fx", "letterbox", 4)
```
## The three strengths are three events
- **light** — a thin white star, 40 sheet pixels, four frames. No shake, three
frames of hitstop.
- **medium** — a hot core inside an ember ring with grit thrown out of it. A
nudge of shake, six frames of hitstop.
- **heavy** — one frame of pure white-out at the contact point, then an ember
shockwave with eight long spokes, debris and smoke. A directional camera
kick, a frame tilt, twelve frames of hitstop and a two-frame screen flash.
Plus **block** (a cool guard flash that faces the attacker), **super** (the
biggest single thing in the game), and dust for landings and dashes.
## How it is built
Every effect is a pooled pixel canvas whose frames are drawn once, at boot, and
then only stepped. Nothing is drawn at fight time: a canvas write costs tens of
milliseconds and `setActiveFrame` costs a fifth of one. The pools live under a
`temporary` parent so none of it is saved into the scene, and sparks draw at
z = 4.6 — in front of the stage's foreground layer, because an impact hidden
behind a lamp post is an impact the player did not see.
The full-frame overlays (flash, speed lines, cinematic bars) parent to the
camera, so they cover the frame at every zoom. A "fade" is a scanline dither
rather than an alpha ramp: a pixel canvas has no alpha, and an opaque grey
sheet is just an opaque grey sheet.
## Surface
`spark(x, y, kind, opts)` · `dust(x, y, kind, opts)` · `shake(mag, frames)` ·
`hitstop(frames)` · `flash(color, frames, startStep)` · `curtain(color, step)` ·
`curtainOff()` · `clearOverlays()` · `speedlines(frames)` · `letterbox(rows)` ·
`update(dt)` · `build()` · `teardown()`
`opts.dir` accepts a number or a word (`"left"` / `"right"`); it is normalised
on the way in.
# staging.attract
The art the opening is made of. Ten painted pixel canvases and the placement
rules for them; the timeline that drives them lives in
`emberline.staging.director`, so the opening can be recut without touching a
pixel.
| Sheet | What it is |
|---|---|
| `sky` | the smog, its sodium bloom, drifting soot, a cold moon |
| `far` | the Kessai skyline: towers, chimneys, lit windows, beacons, the flooded waterline |
| `mid` | the viaduct — arches, deck, rail, signal gantries, lamps, rain |
| `near` | the platform edge, railing, a close sodium lamp, cables, heavy rain |
| `train` | the Emberline crossing, lit carriages and a firebox |
| `ember` | the Ember itself, eight frames of pulse |
| `title` | the EMBERLINE logotype and its subtitle |
| `roster` | MAG, KITE and BELL, revealed one per frame |
| `warden` | the Warden, eight frames of lantern swing |
| `type` | the opening's writing, one frame per line |
## Placement
The opening is a **curtain**, not a stage: its layers sit between the fighters
(z = 0) and the camera (z = 14), in front of everything the fight puts on
screen, because an attract sequence has to read as one place whatever is
standing behind it. The depths keep their order, so the parallax still
separates them — `Attract.parallax(camX)` moves each painted layer by
`camX * (1 - p)` with the factors from `Rules.LAYERS.parallax`.
Cards are type, so they hold their share of the screen whatever the camera is
doing: `Attract.pin(key, camX, camY, camHeight)` scales and places them against
the live framing. A card left at world scale grows out of frame the moment a
beat pushes in.
## Building
`beginBuild()` then `buildStep()` until it returns true draws one canvas per
call, so the cost can be spread over frames; `buildAll()` does the lot at once.
Everything lands under a `temporary` parent, so none of it is saved into the
scene. `teardown()` removes it.
`show(key, frame, x, y, z)` / `hide(key)` / `hideAll()` / `frame(key, n)` /
`move(key, x, y, z)` are what the timeline drives. Omit `z` and the sheet takes
its resting depth from `Attract.LAYOUT`.
# rules
<!-- 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.