art
The sprite runtime every slice draws through, and the hand-painted artwork of the plague doctor and the infected.
Art — NINE BELLS
The sprite runtime every slice draws through, and the hand-painted artwork of the plague doctor and the infected.
What it provides
NB.provide.makeSprite(spec) -> handle
The way anything in this game gets a picture on screen. Installed by
art.start(), which the scene entrypoint calls before traverse, rounds,
backdrop, kit and stage start — so it is there by the time you need it.
A sprite is one quad wearing one material. An animation lives in a sheet:
the material's frame picks a cell out of a cols x rows grid, so playing an
animation is writing a number. Nothing spawns a quad per frame.
local s = NB.makeSprite({
texture = "<texture guid>", -- the sheet. required (unless `sheets`)
cols = 2, rows = 2, -- the grid it is cut into. default 1 x 1
inset = 0.035, -- crop this fraction off each cell's edges
x = 0, y = 0, z = NB.Z.play,
width = 2.9, height = 2.9, -- WORLD UNITS
pivot = "bottom", -- "bottom" (default) = (x, y) is the FEET
parent = "CityRoot", -- entity, entity name, or nil for FXRoot
name = "doctor",
-- Several sheets: a character's animations usually arrive as separate
-- images. Name them, and an animation says which one it plays from.
sheets = { idle = { texture = "<guid>", cols = 2, rows = 2 }, ... },
anims = {
idle = { sheet = "idle", frames = { 0, 1, 3, 2 }, fps = 3.5, loop = true },
run = { sheet = "run", row = 0, from = 0, to = 3, fps = 11, loop = true },
land = { sheet = "act", frames = { 0 }, fps = 3, loop = false,
next = "idle", scale = 1.0, offsetY = -0.30 },
},
anim = "idle",
tint = { 1, 1, 1, 1 }, -- multiplies
emissive = { 0, 0, 0, 1 }, -- ADDED. black unless you want glow
flip = false,
key = "lean", -- "alpha" | "lean" | "color" | "checker"
keyColor = { 1, 0, 1, 1 }, keyTolerance = 0.70, keySoftness = 0.06,
keyDespill = 0.85,
cutoff = 0.35,
static = true, -- scenery mode, see below
})
The handle
| Call | Does |
|---|---|
s:setAnim(name, restart?) | play a named animation. Already playing it is a no-op unless restart, so a state machine may call it every frame |
s:setPos(x, y, z?) | world position of the pivot |
s:setFlip(bool) | face the other way — no second sheet |
s:setTint(r, g, b, a) | multiply |
s:setEmissive(r, g, b, a) | add — lantern glow, ember, a hit flash |
s:setFrame(n) | set the cell directly; stops the animation |
s:setSize(w, h) | world units |
s:setVisible(bool) / s:setParent(p) | |
s:destroy() | despawns the quad and frees its material |
Fields: s.entity (the EntityProxy), s.anim, s.finished (a non-looping
animation has ended), s.x/.y/.z.
Every method is a no-op after :destroy() rather than an error — a sprite
outliving its owner by a frame is normal and should not take a slice down.
Three things about animations that are easy to get wrong
- On a ONE-frame animation that does not loop,
fpsis how long the pose is HELD beforenexttakes over —1 / fpsseconds. A landing atfps = 30is a landing nobody sees. scaleandoffsetYare per animation. Separately drawn sheets never agree about how much of a cell the figure fills or where its feet sit, so an animation may resize and nudge its own quad. The doctor's values were set by standing every pose on a known ground line and looking at the boots.- A sheet swap is free. An animation naming a different
sheetswaps the texture and the grid on the material it already has.
Keying. The image generators do not write an alpha channel. Generate art
over a flat saturated colour that appears nowhere in the picture and key it out.
key = "lean" is the one to use: it scores how far a pixel leans toward
keyColor's own channels and away from the others, which takes the flat
backdrop and the half-strength spill a generator paints for smoke, glass and
soft edges, while a warm rim light or a bone-white mask sharing part of the hue
survives. A plain RGB distance (key = "color") cannot do that, because the
texture is sampled in linear space while the key colour is authored in display
terms. keyDespill pulls the background's hue back out of the pixels that
survive, which is what removes the coloured fringe on the cut edge.
key = "checker" handles the grey/white transparency checkerboard a generator
paints when asked for a transparent background. key = "alpha" trusts the
texture's own alpha.
inset saves a sheet a generator drew grid lines on. Asked for a grid, the
generators draw the borders they were told not to. inset walks the sampled
window in from each cell's edges as a fraction of the cell, cropping the seam
off instead of ringing every frame with it. The doctor's action sheet needs
0.035.
static = true is the scenery mode, and the city slice wants it. Static
sprites SHARE one material per distinct (texture, frame, look), so a thousand
tiles cost a handful of materials instead of a thousand. :setAnim does nothing
on a static sprite; :setFrame re-shares onto the material for that cell.
The artwork
art.SHEETS maps a name to its sheet (texture guid, cols, rows, inset).
art.doctorSpec(overrides) and art.infectedSpec(kind, overrides) hand back a
ready spec so no other slice repeats the frame arithmetic or re-tunes the key:
local art = require("game.art")
local body = NB.makeSprite(art.doctorSpec({ parent = "doctor" }))
body:setAnim("run")
The doctor's body is already on screen. art.start() hangs it under the
doctor anchor and publishes the handle as NB.ext.artDoctor — drive it
with :setAnim(...). It stands off if a body carrying real artwork is already
hanging there, so a slice that makes its own from art.doctorSpec() wins and
the doctor is never drawn twice.
Doctor animations — idle, run, jump, rise, fall, land, hang,
ride, vault, probe, censer, drink, treat, down. Four sheets, one
action each, drawn from one fixed description of the figure so he is the same
man throughout. Ask for a name that is not there and the sprite says so once in
the log and keeps playing what it had.
Infected kinds — bearer (stooped under a hooded sheet, a bundle held to
the chest), crawler (low and horizontal, dragging itself), watcher (tall and
thin, head tipped back at the sky). Animations idle, walk, recoil, calm.
Reads
game.nb — the shared spine. Keeps its own session state under NB.ext.
Design
See /zero/source/design/spec.md.
Scoped to this part · feeds back into the world's score.