# Isometric engine: development guide

A small pixel-art isometric engine for scroll-driven site sections, plus a
visual scene editor. Plain ES modules, no build step.

```
iso/
  engine/        the engine (import these from pages)
  editor/        the scene editor
  scenes/        scene files (*.json), one per section
  tools/         dev-server.mjs
  how-it-works.html   example page
  preview.html        generic preview: preview.html?scene=<name>
```

The modules have to be served over http (Apache or the dev server), not opened
as local files.

## Running

```
node iso/tools/dev-server.mjs
```

Then open `http://localhost:5178/iso/editor/` (forward port 5178 if you're on
the remote machine). With the dev server, **Save** writes
`iso/scenes/<name>.json`. Opened from the public site instead, Save downloads
the file and you copy it into `iso/scenes/` yourself.

Drafts autosave in the browser. **Preview page** opens the unsaved draft in a
scrolling page (`?draft` makes a page read the draft instead of the file).

## Core ideas

- **Blocks** sit on a grid, one per cell (`x`, `y`, `z`; `z` is up). Every
  block is the same size. +x runs down-right on screen, +y down-left.
- **Groups** are like layers. Every block belongs to one group, or to none.
- **Beats** animate a whole group over a slice of the timeline (0 to 1).
- **Markers** split the timeline into steps. On the site, scrolling past a
  marker plays the next step in real time, and the scene rests on a marker
  when the visitor stops scrolling. Set `playback: "scrub"` to map scroll
  position straight onto the timeline instead.
- **Callouts** are HTML labels with a leader line to a block.
- The timeline is a pure function of progress, so anything can be played
  backwards.

## Editor workflow

1. **Build:** pick a block in the palette and click a face to place next to
   it. Drag to paint a row or floor, right-click to erase, R to rotate.
2. **Group:** select blocks (V, then click, Shift-click, or drag a box; Ctrl-drag
   selects every block in the cells between the start cell and the end cell), then
   press G. Keep each step's things in their own groups (e.g. "Intake pile",
   "Desk"). **Selection tint** in the top bar toggles the wash on selected
   blocks and on the blocks a beat animates.
3. **Animate:** set the active group, move the playhead, press N to add a
   beat. If some blocks are selected, and they aren't one whole group, the
   beat targets just those blocks. In the Beat tab, set the effect, order, and stagger. Drag bars in
   the timeline to adjust timing.
4. **Steps:** add markers (M) where each step ends. Put the step's beats
   between its markers.
5. **Label:** select a block, then "+ Callout on this block". Drag the label
   to place it.
6. **Check:** the Preview toggle shows the animation at the playhead, and
   Camera view shows exactly what the site shows. Use Preview page for the
   real scroll behaviour.

**Several blocks in one cell:** with Place, click the top of a table, a
shelf board, a box, or anything else, and the new block rests on that
surface, even inside the same cell. The block gets a `lift` (a fraction of a
cell). A cell only counts as full where the actual shapes would overlap, so
items can fill a shelf as long as they fit. Fine-tune it with "Lift (16ths)"
in the Selection tab. Moving a shelf doesn't move what's on it, so drag a
box around both to move them together.

**Changing blocks over time (no groups needed):**

- **Turn into…** Select blocks, move the playhead, pick a block under
  "Turn into" in the Selection tab, and press "At playhead". That adds one
  flip beat for exactly those blocks.
- **Record mode** (● Record or K). Move the playhead, then build as usual.
  Repaint (T) turns blocks into the palette block, Place drops new blocks
  in, and Erase sinks blocks away. Each stroke becomes one beat at the
  playhead, in the order you painted. The options next to the button pick
  flip or instant swap, and how many seconds each block takes. Nothing is
  deleted or retyped while recording. To make the real change, turn Record
  off first.
- With "Only what exists at playhead" on, Edit mode shows the scene as it
  is once everything up to the playhead has happened, including changed
  types. So you can record step 2's changes on top of step 1's result.
- The Selection tab lists everything that happens to a single block. Click
  an entry to jump to it, or ✕ to take the block out of that change.
- Without Record, Repaint just changes the block's type for good.

**Room to build:** in Edit mode you can build anywhere on the grid; the
camera (dashed outline) is only what the site shows at once. In the Scene
tab, raise **Grid size** to add room on the bottom edges. To add room on the
top-left or top-right edge, use **+ Room top-left** / **+ Room top-right**
or **Shift all** by x, y, z. That moves every block, camera key and beat
origin, grows the grid to fit, and can be undone.

Press `?` in the editor for all shortcuts.

## Putting a scene on a page

```html
<link rel="stylesheet" href="iso/engine/iso.css">

<section class="iso-chapter" id="how" style="height: 400vh">
  <div class="iso-sticky">            <!-- position: sticky; height: 100vh -->
    <div class="iso-steps">
      <article data-from="0" data-to="0.25">Step 1 text</article>
      <article data-from="0.25" data-to="0.5">Step 2 text</article>
    </div>
    <div class="iso-stage"></div>     <!-- the canvas is created in here -->
  </div>
</section>

<script type="module">
  import { ScenePlayer, loadScene } from './iso/engine/player.js';
  const scene = await loadScene('how-it-works', 'iso/scenes/');
  new ScenePlayer({ root: document.getElementById('how'), scene });
</script>
```

- Elements with `data-from` / `data-to` get the class `is-active` for the
  step they cover. Style that class to show and hide the text. Match the
  ranges to the scene's markers.
- The canvas scales by the largest whole number that fits `.iso-stage`
  (`maxScale` option, default 5).
- Other options: `onBlockClick(block)`, and `Renderer` to swap the backend.
  `player.setProgress(p)` pins the scene at a point, which is handy for
  testing.
- Style callouts and info cards with the CSS variables in `iso.css`
  (`--iso-callout-bg`, `--iso-callout-line`, `--iso-callout-ink`,
  `--iso-font`).

`how-it-works.html` is a full example.

## Scene file

```jsonc
{
  "name": "how-it-works",
  "size": [16, 8],          // grid cells (x, y)
  "view": [8, 8],           // cells shown at once on the site
  "headroom": 3,            // how many blocks tall the scene can get
  "duration": 10,           // seconds to play the whole timeline
  "playback": "steps",      // or "scrub"
  "maxTransition": 3,       // cap in seconds for multi-step jumps
  "render": { "outline": true, "edgeHighlight": true, "shadows": true,
              "snapShading": false, "hover": "info",
              "light": { "top": 1, "left": 0.8, "right": 0.62 } },
  "markers":  [{ "at": 0.25, "name": "Data" }],
  "camera":   [{ "at": 0.5, "x": 0, "y": 0 }, { "at": 0.58, "x": 8, "y": 0 }],
  "blockTypes": { "tall_plant": { "name": "Tall plant", "category": "Custom",
                  "parts": [{ "min": [0.3, 0.3, 0], "max": [0.7, 0.7, 1],
                              "top": "leaves:#6d8f57", "side": "leaves:#6d8f57" }] } },
  "groups":   [{ "id": "desk", "name": "Data desk", "color": "#9a7150" }],
  "blocks":   [{ "id": "b1", "type": "table", "x": 2, "y": 3, "z": 1,
                 "rot": 0, "group": "desk", "info": "Shown on click" },
               { "id": "b2", "type": "box", "x": 2, "y": 3, "z": 1,
                 "lift": 0.6 }],   // same cell, resting on the table top
  "beats":    [{ "id": "t1", "name": "Desk rises", "group": "desk",
                 "effect": "rise", "start": 0.3, "end": 0.35,
                 "order": "sweep", "stagger": 0.5 },
               { "id": "t2", "name": "→ Grass", "blocks": ["b1"],  // instead of group
                 "effect": "flip", "to": "grass", "start": 0.4, "end": 0.45,
                 "order": "listed", "stagger": 0 }],
  "callouts": [{ "id": "c1", "block": "b1", "text": "Label text",
                 "from": 0.3, "to": 0.5, "dx": 80, "dy": -60 }]
}
```

Camera keys move the view across the grid in cells and ease between keys.
There's no zoom.

### Beat effects

| effect | does | extra fields |
| --- | --- | --- |
| `drop` | falls in with a small bounce (block hidden before) | `height` |
| `rise` | rises out of the ground (block hidden before) | |
| `sink` | sinks into the ground (block hidden after) | |
| `flip` | spins 180° and turns into another block type | `to`, `lift` |
| `swap` | turns into another block type at once | `to` |
| `hop` | jumps to a new cell and stays there | `by: [dx, dy, dz]` |
| `spin` | turns in place | `turns` |

If a block's first beat is `drop` or `rise`, the block is hidden until that
beat starts. A beat animates its `group`, or the `blocks` id list if it has
one. With `listed` order, a block list plays in list order. Ids of deleted
blocks are removed from these lists, and a beat with an empty list is
deleted.

**Orders:** `together`, `sweep`, `reverseSweep`, `outward` / `inward` (from
`origin: [x, y]`), `bottomUp`, `random`, `listed`. **Stagger** runs from 0
(all blocks at once) to near 1 (one block after another).

## Adding block types

### In the editor (block designer)

Click **+ New block** at the top of the palette, or double-click any palette
block to edit it. You can also use **Edit block** / **Copy as new type** in
the Selection tab.

- A block is a stack of boxes. Pick a box in the list, set its extents (0 to
  1 in the cell), and set textures for its top and sides. Tick Front / Right
  / Back / Left to give one side its own texture (e.g. a screen or a door).
- The preview highlights the selected box. Turn on **Spin** to check the back
  faces, which show during flips.
- **On a table** makes it sit on a table top (place it one cell above a
  table).
- Custom types are saved in the scene file under `blockTypes`, and pages
  using that scene load them automatically. They show with a ✎ in the
  palette. Undo covers them like any other change.
- Custom types belong to one scene. To reuse one in another scene, copy its
  `blockTypes` entry into that scene's JSON for now.

### Editing built-in blocks (all scenes)

Editing a built-in block (grass, wall, table...) changes it everywhere. The
edit is written straight to `iso/blocks.json` (not the scene, and not
covered by undo). Edited built-ins show a • in the palette, and **Reset to
original** in the designer undoes the edit. The ID can't change, because
scenes refer to it.

- Without the dev server, the editor keeps the edits in this browser and
  downloads `blocks.json`; put it in `iso/`. `preview.html?draft` uses the
  browser copy. Next time the editor runs with the dev server, it writes the
  browser copy to disk.
- `loadScene()` applies `blocks.json` before returning. If you build a
  scene object yourself, call `await loadLibrary()` from `engine/blocks.js`
  first.
- `blocks.json` overrides the definitions in `engine/blocks.js`. Code edits
  to a built-in that's also in `blocks.json` won't show until you reset it.

### In code

Edit `engine/blocks.js` for types every scene should have. A block is one or more boxes inside its cell
(coordinates 0 to 1; `z` 0 is the cell floor):

```js
lamp: {
  name: 'Floor lamp', category: 'Objects',
  parts: [
    box([0.4, 0.4, 0], [0.6, 0.6, 0.8], { all: '#4d4f55' }),
    box([0.25, 0.25, 0.8], [0.75, 0.75, 1], { all: 'noise:#e8d6a0' }),
  ],
},
```

- **Faces:** `top`, `px`, `py`, `nx`, `ny`. `side` covers all four sides and
  `all` covers everything. `py` (+y) is the "front", the face you see on the
  left at rotation 0.
- **Items that sit on a table:** wrap the parts in `onTable(...)` and place
  the block one cell above the table.
- **Outlines:** blocks with no box running edge to edge (drives, boxes,
  bins) are drawn as separate items, outlined even against things they
  touch. Walls, floors and shelves join their neighbours. For an object
  built from several cells, like a long chair or a two-cell cabinet, give
  each piece the same `joinGroup` (Joins with, in the block designer): the
  pieces blend into each other but stay outlined against the desk.
  `outline: 'join'` makes a block blend into everything like a wall, and
  `outline: 'separate'` forces item outlines.
- New types appear in the editor palette automatically. Use the same palette
  of colours across types so scenes stay cohesive.

## Textures

Face textures are strings:

- `"#rrggbb"` is a flat colour.
- `"pattern:#base"` or `"pattern:#base:#accent"` is a generated pattern
  (16×16 on tops, 16×19 on sides). Patterns: `noise grass concrete planks brick tiles carpet
  cardboard leaves plaster window door screen tower books label checklist drawers clear glass`.
- `"img:name"` is your own pixel art, usually made in the editor (below).
  `"img:name:#rrggbb"` tints a grayscale image: white becomes that colour
  and black stays black. From code you can also register one before rendering:

  ```js
  import { loadImageTexture } from './iso/engine/textures.js';
  await loadImageTexture('desk-top', 'iso/art/desk-top.png'); // 16×16 for tops, 16×19 for sides
  ```
- `"clear"` is an invisible face (a hole; you see the far side of the box).
- `"glass:#base"` is a see-through tint. Pixel alpha in a painted texture
  works the same way: 0 is a hole, anything in between blends over what's
  behind.

Side textures read the way you see them: row 1 is the top of the face and
column 1 is its left edge, viewed from outside the block.

### Painting and uploading textures in the editor

Every texture row in the block designer has **Paint…** and **Upload…**.

- **Paint…** opens the pixel painter, starting from how the face looks now.
  It has Pencil (B), Fill (G), Eraser (E, cuts holes), Pick colour (I, or
  right-click / Alt-click), Opacity, Mirror (X), Undo/Redo (Ctrl+Z /
  Ctrl+Shift+Z), Darker/Lighter for the paint colour, Darken/Lighten ([ / ])
  and Rotate (R clockwise, Shift+R the other way) for the whole image, and
  swatches of the colours already in the texture. **Fringe → Generate**
  cuts a ragged fade in from one edge (the colours stay, the rest becomes
  holes) so an overlay can blend into the next block. Depth is how far it
  reaches; click again for a different scatter. Undo restores the texture.
  The cube on the right is a live preview.
- **Size** defaults to the face: 16×16 for tops, 16×19 for sides. Custom
  sizes up to 64×64 work too; texels then get smaller than screen pixels.
- **Start from** fills the canvas with any pattern in the current colour,
  which is a quick way to tweak brick, planks, etc.
- **Upload…** loads an image scaled to the canvas size with hard pixel
  edges. Tick **keep its size** to keep its own size (up to 64×64).
  Transparency is kept. If the image is grayscale, **Tint with a colour**
  is offered: the pixels are stored as brightness, and each face that uses
  the texture picks its own tint (saved as `"img:name:#rrggbb"`).
- Saved textures are listed under **Your textures** in every texture menu.
  For a face that already uses one, **Edit pixels** changes it everywhere
  and **Copy…** paints a new one from it.

Textures are shared by every scene and saved right away to
`iso/textures.json` (PNG data inside JSON). Without the dev server they're
kept in this browser and `textures.json` is downloaded, the same as block
edits. `loadScene()` loads the file automatically; if you build a scene
object yourself, call `await loadTextureLibrary()` from `engine/textures.js`
first.

To add a pattern, call `registerPattern(name, fn)` in `textures.js` (see the
existing ones). Keep the brightness range narrow, so face shading stays the
main depth cue.

## Adding effects and orders

Effects live in `EFFECTS` in `engine/timeline.js`:

```js
pop: {
  label: 'Pop in',
  entry: true,                 // hidden until the beat reaches it
  apply(s, t, beat) {          // t runs 0 to 1 for this block
    s.oz += 0.3 * Math.sin(Math.PI * t);   // vertical offset in blocks
    s.alpha *= t;
  },
},
```

The state you can change:

- `x y z` for position; changes stick after the beat ends.
- `oz` for a temporary vertical offset.
- `angle` in radians around the vertical axis.
- `type`, `alpha`, and `clip` (hide anything below the block's floor).

Orders live in `ORDERS`; each returns a number per block, normalised to a
delay. The editor lists new effects and orders automatically.

## Swapping the renderer

Pages and the editor only use these renderer methods:

```
setLayout({ width, height, origin })   native pixel size
setScale(n)                            whole-number CSS scale
setOptions(renderOptions)
render({ items, camera }, { tints, ghosts })
pick(cssX, cssY) -> { item, face } | null
toCss(x, y, z) -> { x, y }             for callouts
```

`items` are timeline states (`x y z oz angle type alpha clip clipZ block`).
A WebGL or three.js backend only needs to implement these and can be passed
as `new ScenePlayer({ Renderer })`. The projection maths is in
`projection.js`: 2:1 pixel lines on top, and blocks are `BLOCK_H` = 19 px tall
so a 1×1×1 block reads as a true cube. Depth is `x + y + DEPTH_Z·z`
(`DEPTH_Z` = 16 / 19, the view direction for that height). Change `BLOCK_H` to make blocks taller or shorter; everything else follows.

## Gotchas

- **Two blocks can share a cell** if they belong to different groups that
  are never visible at the same time. The editor's "Only what exists at
  playhead" view relies on this.
- **Anything off the view still renders** if it's inside the canvas when the
  camera pans. Sink old areas out during a pan, as the How It Works scene
  does.
- **Callout ranges should end at or after their step's marker,** or the
  label disappears before the step finishes settling.
- **Browsers cache the JSON files.** If changes don't show, hard-refresh, or
  use `?draft` while iterating.
