elya-snes / docs /ART_SPEC.md
elyanlabs's picture
Source snapshot of github.com/Scottcjn/elya-snes@544ccea
84f8160 verified
|
Raw History Blame Contribute Delete
6.61 kB
# Art spec β€” draw against these numbers
Everything here is a hardware constraint, not a style preference. Art that
matches these drops straight into the pipeline; art that does not has to be
resampled, and resampling is what made the logo need 704 tiles instead of 109.
## Elya, canon β€” this section is normative
From `patch-and-veil-elya-design-lock.md`. Written here so it cannot drift
again: the first sprite batch drifted on all four counts below, and drift in a
character is not something a palette check or a tile count can catch.
| | |
|---|---|
| **hair** | **long uncut AUBURN-RED, past the waist.** Not brown. Not dark. |
| **dress** | **BROWN Victorian** β€” high collar, long sleeves, floor-length skirt |
| **NOT** | **no apron. No maid gear.** She is not a servant and is not dressed as one |
| **build** | petite adult woman, mid-twenties β€” **never childlike** |
| **NOT** | **no earrings, no jewellery, no lipstick, no makeup.** Image models add all four by default and every one of them has to be explicitly negated |
| **facing** | **RIGHT.** OAM's H-flip is free, but only if the source faces the way the game runs |
| **read** | **16-bit, not 8-bit.** She should fill her 32x32 cell and carry visible detail β€” hair mass, collar, sleeve, hem β€” not read as a 16x16 character scaled up |
The last one is a size statement, not a style one: a figure that occupies 13
of 32 pixels of width has three pixels of face and no room for the dress to be
a dress. Fill the cell.
`tools/mkart.py` will **not** build a sprite sheet from art that has not been
checked against this list. `art_spec_check()` is that check, expressed as
numbers, and it runs on every build against whatever is about to be baked:
| check | threshold | which drift it catches |
|---|---|---|
| width | **>= 24 of 32** | "fill the cell" β€” a narrow figure has no room for a face |
| fill | **>= 45%** | same, by area |
| skin | **>= 28 px** | a face needs pixels; the placeholder had 16 and had none |
| white | **<= 12 px** | **apron / maid's collar.** A wide flat white collar is maid gear by another name |
| hair | **>= dress/3** | "long uncut, past the waist" |
| facing | skin centroid right of hair centroid | **faces RIGHT** |
The white budget is there because the palette trick was not enough. The object
palette was deliberately scrubbed of apron white so a maid outfit would have
nowhere to live β€” and the drift came back anyway, through the *shape*, as a
broad flat collar drawn in the one white entry the collar legitimately needs.
A palette check cannot catch that. A pixel budget can.
Elya herself is hand-placed at native size in `mkart.py`, in the same idiom as
the `@` block and the nabla, because two rounds of generated art failed this
list and downscaling a 512 px render into a 32 px cell spends most of the cell
on mush. Generated art may still override her β€” but only if it passes the gate.
Keying on "the file exists" is how the apron got in the first time; the
filename never knew what was in the picture.
## Canvas
**256 x 224.** That is the whole NTSC screen. Design at 1x β€” not at 4x and
downscaled. Downscaling invents intermediate colours at every edge, and on a
4bpp target those either get quantised away (mush) or eat palette entries
(expensive). Hand-placed pixels at native size beat any downscale.
If you must work large, work at an **integer multiple** (2x = 512x448, 4x =
1024x896) with hard edges and no antialiasing, then nearest-neighbour down.
## Sprites
Object size is set **globally**, one pair for the whole screen (`OBSEL`). The
useful pair for a platformer is **16x16 and 32x32**:
| object | slot | notes |
|---|---|---|
| Elya | 32x32 | gives room for a jump pose and a foot-tap without swapping size |
| `@` block | 16x16 | |
| coin | 16x16 | |
| nabla | 16x16 or 32x32 | whichever the design needs β€” pick before drawing |
* **16 colours per sprite palette, entry 0 transparent β€” so 15 usable.**
* Sprites use palettes 8-15; backgrounds use 0-7. They are separate budgets,
so sprite colour does not compete with the sky.
* **128 sprites on screen; 32 per scanline.** A 32x32 Elya costs four 16x16
slots' worth of scanline budget. Not a concern for this design, but it is why
you do not get an army of nablas.
* Horizontal and vertical flip are **free** in OAM. Draw Elya facing one way
only; walking left costs nothing extra.
## Background
* 8x8 tiles, **4bpp, 15 colours + transparent**.
* Flat areas deduplicate to a single tile, so large fields of one colour are
effectively free. **Texture is what costs VRAM**, not size.
* H/V flip works in the tilemap too β€” symmetric scenery is half price.
* The sky gradient is HDMA and costs no tiles at all. Do not draw a gradient
into the background art; it will be generated.
## Palette discipline β€” the thing that actually bites
The logo taught this the hard way: **the source red was not flat** (11,610
unique colours), and every extra palette entry fragmented the background into
hundreds of near-identical tiles.
So: **use exact, repeated colour values.** If two areas are meant to be the same
red, they must be the *same* RGB triple, not two reds that look alike. In your
editor, work from a fixed swatch set and turn antialiasing OFF.
A good starting budget for a platformer scene:
| use | entries |
|---|---|
| Elya skin/hair/dress | 6-8 |
| `@` block + coin (gold ramp) | 3-4 |
| nabla | 3 |
| ground/scenery | 6-8 |
| **sky** | **0 β€” HDMA** |
## Animation
Frames cost VRAM linearly, so budget them:
| animation | frames | why |
|---|---|---|
| idle bob | 2 | |
| **foot tap** | **4** | the loop that runs while she waits on you |
| jump | 1 | |
| land | 1 | |
| coin spin | 4 | classic rotation reads at any size |
| block struck | 1 | squash frame |
That is ~13 sprite frames. At 32x32 4bpp a frame is 512 bytes, at 16x16 it is
128. Call it 8 KB of VRAM for everything, against 64 KB total and 5.2 KB already
spent on the logo.
## What to send me
PNG, native resolution, no antialiasing, hard edges, transparent background for
sprites (real alpha, not a magenta key). I will run it through
`tools/png2snes.py` and send back a preview of **exactly what the PPU renders**,
plus the tile count, before anything gets baked into the ROM.
## One boundary
Original art in the familiar idiom, not the familiar art. The mechanic β€” jump,
strike block, collectible pops out β€” is not protectable and never has been. The
specific Mario sprite, the coin sprite, the `?` glyph and the sounds are. That
is why the block is stamped `@`, and it is a better joke anyway.