Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions apps/mon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
# SPARKWOOD

An original creature-collecting adventure, and the content half of the Pocket
Mon runtime ([docs/MON.md](../../docs/MON.md)).

Everything here is source. There is no ROM, no importer, and no asset pipeline
that reads one — the creatures, the world, the words and the art are all
written or generated in this directory and cooked into a single `.monpak`.

```
content/game.ts species, moves, types, items, maps, trainers, scripts
content/text.ts the string table (a key IS an index; no runtime hashing)
art/font.ts a 5x7 pixel face, hand-authored
art/tiles.ts 8x8 terrain tiles, the 4x4-tile blocks, tile behaviours
art/actors.ts procedural 16x16 walk sheets, twelve poses each
art/creatures.ts procedural 64x64 portraits, front and back
art/palette.ts the one 256-entry CLUT the whole game shares
cook.ts all of the above -> dist/sparkwood.monpak
sdk.ts the guest-side algebra over the `mon` surface
tapes/story.tape the acceptance run
```

## Working on it

```sh
bun tools/mon.ts cook # rebuild the pak
bun tools/mon.ts check # play the acceptance tape, assert the frame hashes
bun tools/mon.ts shots # write a PNG per checkpoint, and the atlas pages
bun tools/mon.ts psp # build the PSP EBOOT with the pak baked in
bun tools/mon.ts run # …and launch it in PPSSPP
```

`shots` is the one to reach for first: content bugs are visual, and a
screenshot per checkpoint plus the atlas pages tells you in one look whether
the art is wrong or the drawing is.

## Adding to the world

The content tests (`tests/mon-content.test.ts`) enforce the things that are
easy to get wrong and hard to notice:

- a warp must sit on a cell whose block actually has a door under it — the
bottom-left-tile rule means a door drawn in the wrong quarter of a block is
scenery;
- map connections must be declared from both sides with mirrored offsets, or
walking across a seam and back lands you somewhere else;
- every species needs a move learnable by level 5, or a starter stands there
doing nothing;
- the font must cover every character the content writes, because a missing
glyph is invisible rather than loud.

Cooking is deterministic: the same checkout produces byte-identical output, and
a test asserts it. If a change makes the pak non-reproducible, that is a bug in
the cooker, not a quirk to work around.
141 changes: 141 additions & 0 deletions apps/mon/art/actors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
// Overworld actor sprites: 16x16, twelve poses each (4 facings x 3 walk
// frames), laid out one actor per atlas row exactly as `scene.rs` expects.
//
// Drawn procedurally from a small per-cast description rather than pixelled by
// hand: five characters x twelve poses is sixty sprites, and a parameterised
// figure keeps them all consistent — same silhouette, same walk cadence, so
// the cast reads as one world.

import { PAL } from "./palette.ts";
import { Surface } from "./raster.ts";

export const SPRITE_PX = 16;
export const POSES = 12;

/** Which way an actor faces; matches `spec::dir`. */
export const DIR = { down: 0, up: 1, left: 2, right: 3 } as const;

/** One member of the cast. */
export interface ActorStyle {
name: string;
hair: number;
/** Torso colour. */
shirt: number;
/** Leg colour. */
pants: number;
/** Optional headwear drawn over the hair. */
cap?: number;
/** Optional long garment replacing the legs (a coat or a dress). */
robe?: number;
}

/** The cast, in atlas-row order. Sprite ids in content reference these. */
export const CAST: ActorStyle[] = [
{ name: "player", hair: PAL.hairDark, shirt: PAL.shirtA, pants: PAL.pants, cap: PAL.capA },
{ name: "mom", hair: PAL.hairLight, shirt: PAL.dress, pants: PAL.dress, robe: PAL.dress },
{ name: "professor", hair: PAL.shade, shirt: PAL.coat, pants: PAL.pants, robe: PAL.coat },
{ name: "rival", hair: PAL.capB, shirt: PAL.shirtB, pants: PAL.pants },
{ name: "hiker", hair: PAL.hairDark, shirt: PAL.hilite, pants: PAL.wood, cap: PAL.capB },
{ name: "villager", hair: PAL.hairLight, shirt: PAL.roofMid, pants: PAL.pants },
];

/**
* Draw one pose.
*
* The figure is eight pixels wide inside a sixteen-pixel cell, which leaves
* room for the outline pass and keeps the feet on the walk grid: the sprite's
* bottom row is the cell's bottom row, and the core draws it at the actor's
* cell origin.
*/
export function drawPose(style: ActorStyle, dir: number, frame: number): Surface {
const s = new Surface(SPRITE_PX, SPRITE_PX);
const skin = PAL.skin;

// --- head -------------------------------------------------------------
// Hair cap, then the face inset under it. Facing up shows only hair.
s.rect(4, 1, 8, 4, style.hair);
if (dir !== DIR.up) {
s.rect(5, 3, 6, 4, skin);
// A fringe over the brow, asymmetric when facing sideways.
if (dir === DIR.left) s.rect(5, 3, 3, 1, style.hair);
if (dir === DIR.right) s.rect(8, 3, 3, 1, style.hair);
} else {
s.rect(5, 3, 6, 3, style.hair);
}
// Ears/side hair.
s.rect(4, 4, 1, 2, style.hair);
s.rect(11, 4, 1, 2, style.hair);

if (style.cap) {
s.rect(4, 1, 8, 2, style.cap);
// A brim, on whichever side the actor is looking.
if (dir === DIR.down) s.rect(4, 3, 8, 1, style.cap);
if (dir === DIR.left) s.rect(3, 3, 4, 1, style.cap);
if (dir === DIR.right) s.rect(9, 3, 4, 1, style.cap);
}

// --- eyes -------------------------------------------------------------
if (dir === DIR.down) {
s.set(6, 5, PAL.outline);
s.set(9, 5, PAL.outline);
} else if (dir === DIR.left) {
s.set(6, 5, PAL.outline);
} else if (dir === DIR.right) {
s.set(9, 5, PAL.outline);
}

// --- torso ------------------------------------------------------------
const torso = style.robe ?? style.shirt;
s.rect(5, 7, 6, 5, torso);
// Arms, one either side; the trailing arm swings back a pixel while walking.
const swing = frame === 1 ? 1 : frame === 2 ? -1 : 0;
s.rect(4, 8 + Math.max(0, swing), 1, 3, torso);
s.rect(11, 8 + Math.max(0, -swing), 1, 3, torso);
// Hands.
s.set(4, 11 + Math.max(0, swing), skin);
s.set(11, 11 + Math.max(0, -swing), skin);

// --- legs -------------------------------------------------------------
if (style.robe) {
// A long garment: flare it out instead of splitting into legs.
s.rect(4, 12, 8, 3, style.robe);
s.rect(5, 15, 2, 1, PAL.shoe);
s.rect(9, 15, 2, 1, PAL.shoe);
} else {
// frame 0 = standing, 1 = left leg forward, 2 = right leg forward.
const leftLen = frame === 2 ? 3 : 4;
const rightLen = frame === 1 ? 3 : 4;
s.rect(5, 12, 2, leftLen, style.pants);
s.rect(9, 12, 2, rightLen, style.pants);
s.rect(5, 11 + leftLen, 2, 1, PAL.shoe);
s.rect(9, 11 + rightLen, 2, 1, PAL.shoe);
}

s.outline(PAL.outline);
return s;
}

/**
* The full walk sheet for one actor: twelve 16x16 poses in a row, ordered
* `dir * 3 + frame` to match `scene::actor_uv`.
*/
export function drawSheet(style: ActorStyle): Surface {
const sheet = new Surface(SPRITE_PX * POSES, SPRITE_PX);
for (let dir = 0; dir < 4; dir++) {
for (let frame = 0; frame < 3; frame++) {
const pose = drawPose(style, dir, frame);
pose.blitInto(sheet, (dir * 3 + frame) * SPRITE_PX, 0);
}
}
return sheet;
}

/** Every sheet, stacked one per row — the ACTOR_PAGE atlas. */
export function drawCast(): Surface {
const page = new Surface(256, 256);
CAST.forEach((style, i) => {
if ((i + 1) * SPRITE_PX > 256) return;
drawSheet(style).blitInto(page, 0, i * SPRITE_PX);
});
return page;
}
201 changes: 201 additions & 0 deletions apps/mon/art/creatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Creature portraits: 64x64 front and back views, generated from a body plan
// plus the species' elemental colour ramp.
//
// Fourteen species x two views is twenty-eight portraits. Pixelling those by
// hand would be the single biggest job in the project and would still drift in
// style; a parameterised plan keeps proportions consistent across a family and
// makes an evolution visibly the *same* creature, larger.

import { creature, PAL, RAMP } from "./palette.ts";
import { Surface } from "./raster.ts";

export const PORTRAIT_PX = 64;

/** The silhouettes the bestiary is built from. */
export type Plan = "pup" | "fish" | "sprout" | "mote" | "rock" | "bird" | "moth";

/** How to draw one species. */
export interface CreatureArt {
plan: Plan;
/** Elemental type index; picks the colour ramp. */
type: number;
/** 0..1 — how much of the frame the creature fills. */
size: number;
}

/** Draw the eyes and mouth of a front view. */
function face(s: Surface, cx: number, cy: number, spread: number, scale: number): void {
const r = Math.max(2, Math.round(3 * scale));
for (const dx of [-spread, spread]) {
s.ellipse(cx + dx, cy, r, r, PAL.eyeWhite);
s.ellipse(cx + dx + Math.sign(dx) * 0.5, cy + 0.5, r * 0.55, r * 0.6, PAL.eyePupil);
// A single specular dot does more for the face than any other pixel.
s.set(Math.round(cx + dx - r * 0.35), Math.round(cy - r * 0.4), PAL.eyeWhite);
}
s.rect(Math.round(cx - 2), Math.round(cy + r + 2), 4, 1, PAL.mouth);
s.set(Math.round(cx - 3), Math.round(cy + r + 1), PAL.mouth);
s.set(Math.round(cx + 2), Math.round(cy + r + 1), PAL.mouth);
}

/** Render one creature. `back` draws the rear view: same body, no face. */
export function drawCreature(art: CreatureArt, back: boolean): Surface {
const s = new Surface(PORTRAIT_PX, PORTRAIT_PX);
const dark = creature(art.type, RAMP.dark);
const mid = creature(art.type, RAMP.mid);
const light = creature(art.type, RAMP.light);
const accent = creature(art.type, RAMP.accent);
const k = 0.55 + art.size * 0.45; // overall scale
const cx = PORTRAIT_PX / 2;
const floor = PORTRAIT_PX - 4;

switch (art.plan) {
case "pup": {
const bw = 18 * k;
const bh = 12 * k;
const by = floor - bh - 6 * k;
// legs
for (const dx of [-bw * 0.6, -bw * 0.2, bw * 0.2, bw * 0.6]) {
s.rect(Math.round(cx + dx - 2), Math.round(by + bh - 2), 4, Math.round(8 * k), dark);
}
// tail
s.triangle(cx + bw * 0.8, by, cx + bw * 1.5, by - 8 * k, cx + bw * 0.9, by + 5 * k, mid);
// body + head
s.ellipse(cx, by, bw, bh, mid);
s.ellipse(cx, by + bh * 0.45, bw * 0.75, bh * 0.5, light);
const hx = cx - bw * 0.55;
const hy = by - bh * 0.9;
s.ellipse(hx, hy, 11 * k, 10 * k, mid);
// ears
s.triangle(hx - 8 * k, hy - 6 * k, hx - 3 * k, hy - 15 * k, hx + 1 * k, hy - 5 * k, dark);
s.triangle(hx + 3 * k, hy - 6 * k, hx + 8 * k, hy - 15 * k, hx + 10 * k, hy - 4 * k, dark);
if (back) {
s.ellipse(hx, hy - 2 * k, 7 * k, 5 * k, accent);
} else {
s.ellipse(hx, hy + 5 * k, 5 * k, 3.5 * k, accent); // muzzle
face(s, hx, hy - 1 * k, 5 * k, k);
}
break;
}

case "fish": {
const bw = 20 * k;
const bh = 13 * k;
const cy = PORTRAIT_PX / 2 + 2;
// tail fin
s.triangle(cx + bw * 0.7, cy, cx + bw * 1.6, cy - 11 * k, cx + bw * 1.6, cy + 11 * k, dark);
// dorsal + ventral
s.triangle(cx - 2, cy - bh, cx + 4 * k, cy - bh - 12 * k, cx + 10 * k, cy - bh * 0.7, accent);
s.triangle(cx - 2, cy + bh, cx + 3 * k, cy + bh + 8 * k, cx + 9 * k, cy + bh * 0.7, dark);
s.ellipse(cx, cy, bw, bh, mid);
s.ellipse(cx - bw * 0.15, cy + bh * 0.4, bw * 0.7, bh * 0.45, light);
// pectoral fin
s.triangle(cx - 2 * k, cy + 2, cx + 8 * k, cy + 9 * k, cx + 9 * k, cy - 1 * k, accent);
if (back) {
s.ellipse(cx - bw * 0.3, cy - bh * 0.2, bw * 0.35, bh * 0.5, accent);
} else {
face(s, cx - bw * 0.45, cy - 2 * k, 5 * k, k);
}
break;
}

case "sprout": {
const bw = 13 * k;
const bh = 15 * k;
const by = floor - bh - 4 * k;
s.rect(Math.round(cx - 7 * k), Math.round(by + bh - 2), Math.round(5 * k), Math.round(7 * k), dark);
s.rect(Math.round(cx + 2 * k), Math.round(by + bh - 2), Math.round(5 * k), Math.round(7 * k), dark);
s.ellipse(cx, by, bw, bh, mid);
s.ellipse(cx, by + bh * 0.35, bw * 0.7, bh * 0.55, light);
// a pair of leaves and a stem
s.rect(Math.round(cx - 1), Math.round(by - bh - 8 * k), 2, Math.round(9 * k), dark);
s.ellipse(cx - 9 * k, by - bh - 7 * k, 9 * k, 4.5 * k, accent);
s.ellipse(cx + 9 * k, by - bh - 9 * k, 8 * k, 4 * k, accent);
if (!back) face(s, cx, by - 3 * k, 6 * k, k);
break;
}

case "mote": {
const r = 15 * k;
const cy = PORTRAIT_PX / 2;
// orbiting arcs, drawn behind
s.ellipse(cx - r * 1.5, cy, 4 * k, 9 * k, dark);
s.ellipse(cx + r * 1.5, cy, 4 * k, 9 * k, dark);
s.ellipse(cx, cy, r, r, mid);
s.ellipse(cx, cy + r * 0.3, r * 0.7, r * 0.6, light);
// a spark crown
for (const dx of [-1, 0, 1]) {
s.triangle(
cx + dx * 9 * k - 3,
cy - r + 2,
cx + dx * 9 * k,
cy - r - 10 * k,
cx + dx * 9 * k + 3,
cy - r + 2,
accent,
);
}
if (!back) face(s, cx, cy - 1, 6 * k, k);
break;
}

case "rock": {
const w = 17 * k;
const h = 15 * k;
const cy = floor - h - 2;
// an angular body rather than an ellipse: reads as mineral
s.triangle(cx - w, cy + h, cx - w * 0.6, cy - h, cx + w * 0.2, cy + h, mid);
s.triangle(cx - w * 0.2, cy + h, cx + w * 0.7, cy - h * 0.8, cx + w, cy + h, mid);
s.rect(Math.round(cx - w), Math.round(cy + h - 3), Math.round(w * 2), 4, dark);
s.triangle(cx - w * 0.5, cy + h * 0.4, cx - w * 0.1, cy - h * 0.3, cx + w * 0.4, cy + h * 0.4, light);
// stubby arms
s.rect(Math.round(cx - w - 5 * k), Math.round(cy + h * 0.2), Math.round(6 * k), Math.round(5 * k), dark);
s.rect(Math.round(cx + w - 1), Math.round(cy + h * 0.2), Math.round(6 * k), Math.round(5 * k), dark);
if (!back) face(s, cx, cy + h * 0.1, 6 * k, k);
else s.ellipse(cx, cy, w * 0.4, h * 0.4, accent);
break;
}

case "bird": {
const bw = 12 * k;
const bh = 15 * k;
const cy = PORTRAIT_PX / 2 + 2;
// wings behind the body
s.triangle(cx - bw, cy - 4, cx - bw - 18 * k, cy - 12 * k, cx - bw - 4, cy + 10 * k, dark);
s.triangle(cx + bw, cy - 4, cx + bw + 18 * k, cy - 12 * k, cx + bw + 4, cy + 10 * k, dark);
s.ellipse(cx, cy, bw, bh, mid);
s.ellipse(cx, cy + bh * 0.3, bw * 0.65, bh * 0.5, light);
s.ellipse(cx, cy - bh * 0.85, 9 * k, 8 * k, mid);
// feet
s.rect(Math.round(cx - 6 * k), Math.round(cy + bh - 1), Math.round(4 * k), Math.round(5 * k), accent);
s.rect(Math.round(cx + 2 * k), Math.round(cy + bh - 1), Math.round(4 * k), Math.round(5 * k), accent);
if (back) {
s.ellipse(cx, cy - bh * 0.2, bw * 0.4, bh * 0.4, accent);
} else {
s.triangle(cx - 2, cy - bh * 0.85, cx + 9 * k, cy - bh * 0.7, cx - 2, cy - bh * 0.55, accent);
face(s, cx, cy - bh * 0.95, 4.5 * k, k);
}
break;
}

case "moth": {
const bw = 6 * k;
const bh = 16 * k;
const cy = PORTRAIT_PX / 2 + 2;
for (const sign of [-1, 1]) {
s.ellipse(cx + sign * 15 * k, cy - 6 * k, 14 * k, 10 * k, mid);
s.ellipse(cx + sign * 13 * k, cy + 8 * k, 10 * k, 8 * k, dark);
s.ellipse(cx + sign * 15 * k, cy - 6 * k, 5 * k, 4 * k, accent);
}
s.ellipse(cx, cy, bw, bh, dark);
s.ellipse(cx, cy - bh * 0.6, 6 * k, 6 * k, mid);
// antennae
s.triangle(cx - 2, cy - bh * 0.9, cx - 10 * k, cy - bh - 6 * k, cx - 1, cy - bh * 0.7, dark);
s.triangle(cx + 2, cy - bh * 0.9, cx + 10 * k, cy - bh - 6 * k, cx + 1, cy - bh * 0.7, dark);
if (!back) face(s, cx, cy - bh * 0.6, 3.5 * k, k * 0.8);
break;
}
}

s.outline(PAL.outline);
return s;
}
Loading