emberline.art.magkite — the ink kit MAG and KITE are drawn with.
Owned by the `art-mag-kite` slice. Two fighters share it so their figures
have the same anatomy, the same light and the same finishing passes, and
differ where a character should differ: proportion, costume, and how the
cloth on them moves.
WHY THERE IS A RASTERISER HERE. `emberline.pixelkit` draws straight into a
live PixelCanvas, one engine call per ellipse. A fighter sheet is ~220
frames and each frame is ~40 shapes plus an outline pass that reads every
cell back, which measures at roughly 200 ms a frame — forty seconds of
engine stall per fighter, every boot and every hot reload, on an engine
eight other slices are working on. So the shapes are rasterised into a plain
Luau index buffer and each finished frame is handed over in ONE call:
`handle:addFrame(cells)` takes a flat array of palette indices. 220 frames
land in a third of a second.
Posing, tweening, easing and the cloth follow-chains are pixelkit's, used
exactly as intended: `Pixelkit.tween` turns key poses into frames,
`Pixelkit.blend` interpolates them, `Pixelkit.follow` gives a coat tail and
a scarf their lag.
local Ink = require("emberline.art.magkite")
local B = Ink.buffer(96, 120, PALETTE)
local J = Ink.solve(pose, STYLE)
Ink.body(B, J, STYLE, pose)
B:finish(STYLE)
handle:addFrame(B:cells())
buffer(w: number, h: number, palette: { any }) → any
Build a drawing buffer over a palette. `palette` is the ordered array of
`{ name = , color = }` entries the canvas was created with; the buffer
resolves colour names to that exact index order.
| arg | type | description |
|---|
| w | number | |
| h | number | |
| palette | { any } | |
clear(self: any) → void
| arg | type | description |
|---|
| self | any | |
cells(self: any) → void
| arg | type | description |
|---|
| self | any | |
bbox(self: any) → any
The drawn extent of this frame, or nil when nothing was drawn. A frame
whose ink touches the cell edge is a pose reaching further than the sheet
allows, and the only way to find one across 240 frames is to measure.
| arg | type | description |
|---|
| self | any | |
i(self: any, name: string?) → number
| arg | type | description |
|---|
| self | any | |
| name | string? | |
px(self: any, x: number, y: number, ci: number) → void
Every primitive takes a resolved INDEX, not a name, so the inner loops stay
arithmetic. `Ink.body` and the costume hooks resolve once per shape.
| arg | type | description |
|---|
| self | any | |
| x | number | |
| y | number | |
| ci | number | |
rect(self: any, x0: number, y0: number, x1: number, y1: number, ci: number) → void
| arg | type | description |
|---|
| self | any | |
| x0 | number | |
| y0 | number | |
| x1 | number | |
| y1 | number | |
| ci | number | |
blob(self: any, cx: number, cy: number, rx: number, ry: number, ci: number) → void
A filled ellipse — heads, fists, shoulders, pauldrons, lantern glass.
| arg | type | description |
|---|
| self | any | |
| cx | number | |
| cy | number | |
| rx | number | |
| ry | number | |
| ci | number | |
limb(self: any, x0: number, y0: number, x1: number, y1: number, w0: number, w1: number, ci: number) → void
A tapered capsule. Arms, legs, torso, coat tails, cable, chain — the taper
is what keeps a figure from reading as a stack of rectangles.
| arg | type | description |
|---|
| self | any | |
| x0 | number | |
| y0 | number | |
| x1 | number | |
| y1 | number | |
| w0 | number | |
| w1 | number | |
| ci | number | |
line(self: any, x0: number, y0: number, x1: number, y1: number, ci: number, thick: number?) → void
| arg | type | description |
|---|
| self | any | |
| x0 | number | |
| y0 | number | |
| x1 | number | |
| y1 | number | |
| ci | number | |
| thick | number? | |
poly(self: any, pts: { { number } }, ci: number) → void
A closed filled polygon (scanline). Points are `{ {x,y}, ... }`.
| arg | type | description |
|---|
| self | any | |
| pts | { { number } } | |
| ci | number | |
ribbon(self: any, pts: { { number } }, w0: number, w1: number, ci: number) → void
A tapering strip through a point list — scarves, coat tails, cable, hair.
| arg | type | description |
|---|
| self | any | |
| pts | { { number } } | |
| w0 | number | |
| w1 | number | |
| ci | number | |
speck(self: any, cx: number, cy: number, rx: number, ry: number, ci: number, density: number?, seed: number?) → void
Deterministic scattered pixels in an ellipse — sparks, ash, soot, grit.
| arg | type | description |
|---|
| self | any | |
| cx | number | |
| cy | number | |
| rx | number | |
| ry | number | |
| ci | number | |
| density | number? | |
| seed | number? | |
burst(self: any, cx: number, cy: number, r: number, spokes: number, ci: number, phase: number?) → void
A radiating burst — the shape under every furnace-light effect in the game.
| arg | type | description |
|---|
| self | any | |
| cx | number | |
| cy | number | |
| r | number | |
| spokes | number | |
| ci | number | |
| phase | number? | |
volume(self: any, ramp: { [number]: { number } }) → void
Volume. Walk each row, find maximal runs of one material, and push the
first `dark` columns of the run to its shadow tone and the last `lit`
columns to its highlight. Every stage in Kessai keys from screen-right, so
the light lands on a fighter's leading edge — and because it is done per
RUN rather than per silhouette, an arm crossing a coat gets its own
rounding instead of a flat cut-out.
| arg | type | description |
|---|
| self | any | |
| ramp | { [number]: { number } } | |
grit(self: any, fromIdx: number, toIdx: number, density: number) → void
Wear. Replace a deterministic scatter of one material with another —
soot on a coat, rust on a gauntlet, scuffing on leather. Keyed off the
cell address alone, so the same pixel wears the same way in every frame
and the grain does not crawl during an animation.
| arg | type | description |
|---|
| self | any | |
| fromIdx | number | |
| toIdx | number | |
| density | number | |
outline(self: any, ci: number) → void
One-pixel rim around every drawn cluster, run LAST. A fighting-game sprite
without a hard outline disappears into a painted stage.
| arg | type | description |
|---|
| self | any | |
| ci | number | |
finish(self: any, S: any) → void
Volume, wear and outline in the order a finished cell wants them.
| arg | type | description |
|---|
| self | any | |
| S | any | |
down(a: number) → void
Angles are degrees. 0 is straight down for a limb and straight up for the
torso; positive turns toward +x, which is the direction the sheet faces.
The combat slice mirrors the entity to face the other way, so everything
here is drawn facing screen-right and only ever facing screen-right.
| arg | type | description |
|---|
| a | number | |
up(a: number) → void
| arg | type | description |
|---|
| a | number | |
solve(P: any, S: any) → any
Solve a pose into world (sheet) joint positions.
Returns `{ pelvis, chest, neck, head, shF, shB, elF, elB, hdF, hdB,
knF, knB, ftF, ftB }`, each `{x, y}`, plus `lean` and `scale`.
| arg | type | description |
|---|
| P | any | |
| S | any | |
arm(sx0: number, sy0: number, a: number, b: number) → void
The forearm angle is ABSOLUTE, not a flexion off the upper arm: in a
fighting sheet you pose an arm by deciding where the fist points, and a
relative elbow makes that two sums in your head and the sign of the
flexion easy to get backwards. The knee stays relative, because a knee
is a hinge that only bends one way and reads naturally as "bend".
| arg | type | description |
|---|
| sx0 | number | |
| sy0 | number | |
| a | number | |
| b | number | |
leg(a: number, b: number) → void
| arg | type | description |
|---|
| a | number | |
| b | number | |
body(B: any, J: any, S: any, P: any) → void
Draw the figure back-to-front. `S.mat` names the materials; a `*B` entry is
the far-side tone, which is what separates the two arms and the two legs
without an outline between them.
| arg | type | description |
|---|
| B | any | |
| J | any | |
| S | any | |
| P | any | |
legPart(B: any, hipP: any, knP: any, ftP: any, S: any, iPant: number, iBoot: number, footA: number, sy: number, k: number) → void
| arg | type | description |
|---|
| B | any | |
| hipP | any | |
| knP | any | |
| ftP | any | |
| S | any | |
| iPant | number | |
| iBoot | number | |
| footA | number | |
| sy | number | |
| k | number | |
armPart(B: any, shP: any, elP: any, hdP: any, S: any, iSkin: number, handA: number, k: number) → void
| arg | type | description |
|---|
| B | any | |
| shP | any | |
| elP | any | |
| hdP | any | |
| S | any | |
| iSkin | number | |
| handA | number | |
| k | number | |
torso(B: any, J: any, S: any, iBody: number) → void
| arg | type | description |
|---|
| B | any | |
| J | any | |
| S | any | |
| iBody | number | |
head(B: any, J: any, S: any, P: any, iSkin: number) → void
| arg | type | description |
|---|
| B | any | |
| J | any | |
| S | any | |
| P | any | |
| iSkin | number | |
chain(n: number, x: number, y: number, seg: number) → void
A coat tail and a scarf are follow-chains: pixelkit relaxes each segment
toward the one in front of it with lag, so cloth leads into a movement a
frame late and keeps travelling after the body has stopped. The chain is
state that persists ACROSS the frames of a clip, which is the whole point —
rebuild it per frame and the cloth is rigid.
Create a chain of `n` points seeded hanging from (x, y).
| arg | type | description |
|---|
| n | number | |
| x | number | |
| y | number | |
| seg | number | |
flow(ch: { { number } }, ax: number, ay: number, seg: number, lag: number, gravity: number, wx: number, wy: number, floorY: number?) → void
Advance a chain one frame. `wx`/`wy` is the wind/inertia push applied to
every segment past the first, scaled down the length so the tip whips.
| arg | type | description |
|---|
| ch | { { number } } | |
| ax | number | |
| ay | number | |
| seg | number | |
| lag | number | |
| gravity | number | |
| wx | number | |
| wy | number | |
| floorY | number? | |
settle(ch: { { number } }, ax: number, ay: number, seg: number, lag: number, gravity: number, iter: number) → void
Settle a chain by running it `iter` times with no wind — the shape cloth
reaches when the fighter has been standing still.
| arg | type | description |
|---|
| ch | { { number } } | |
| ax | number | |
| ay | number | |
| seg | number | |
| lag | number | |
| gravity | number | |
| iter | number | |
pose(base: any, ...: ?) → any
Merge `over` onto a copy of `base`. Poses are flat number tables, so this
is how a stance becomes a wind-up without restating twenty joints.
| arg | type | description |
|---|
| base | any | |
| ... | ? | |
clip(fps: number, loop: boolean, keys: { any }) → any
A clip from key poses. `keys` is pixelkit's shape:
`{ { pose = P, hold = n, ease = "outQuad" }, ... }`.
| arg | type | description |
|---|
| fps | number | |
| loop | boolean | |
| keys | { any } | |
cycleClip(fps: number, keys: { any }, n: number) → any
A seamless looping clip. `keys` are `{ pose = P, at = 0..1, ease? }` around
a cycle that WRAPS, so the last frame leads back into the first instead of
landing on a copy of it — which is what a breathing idle and a walk cycle
need and what a straight key list cannot give.
| arg | type | description |
|---|
| fps | number | |
| keys | { any } | |
| n | number | |
buildSheet(handle: any, S: any, clips: { [string]: any }, order: { string }, drawPose: (any, any) → void
Write every clip into the canvas and hand back the clip index the combat
slice drives by name. One `addFrame` per frame; the blank frame the canvas
is born with is removed at the end so clip frame numbers start at 1.
| arg | type | description |
|---|
| handle | any | |
| S | any | |
| clips | { [string]: any } | |
| order | { string } | |
| drawPose | (any, any | |
auditSheet(S: any, clips: { [string]: any }, order: { string }, drawPose: (any, any) → void
Draw the whole sheet into a buffer PADDED on every side and report which
clips put ink outside the real cell. A primitive clamps to the buffer it
draws into, so a pose that reaches past the sheet edge does not fail — it
silently loses the part that did not fit, and across 240 frames the only
way to find one is to give the drawing room to overflow into and then
measure what landed there. Returns `{ [clipName] = { over = n, left, right,
top, bottom } }`, empty when every frame fits.
| arg | type | description |
|---|
| S | any | |
| clips | { [string]: any } | |
| order | { string } | |
| drawPose | (any, any | |