Examples

Sprites

A tilemap garden and a gardener who picks its flowers, one sprite pass, in DriftScript.

Starts examples/sprites in this page, on WebGPU where your browser has it.

Open on its own · Source

Sprites and tilemaps is the chapter that walks through it.

From a checkout of the engine, npm run examples serves it at /sprites/.

Source

main.ts

examples/sprites/main.ts
/**
 * A garden drawn as sprites: a tilemap, a gardener walking it, and flowers to pick.
 *
 * Everything is one sprite pass over one sheet, painted at load: the tilemap draws only the cells in
 * view, and the gardener is a frame of the same sheet drawn after it, so the order of the draws is
 * the order of the layers. `garden.drs` walks the gardener, keeps it out of the pond, picks the
 * flowers it steps on by rewriting their tiles, and draws both. The switches zoom the camera,
 * change how the sheet is filtered, and repaint it at dusk, all on the running page.
 */
import { ActionMap, InputSource } from '@driftengine/core';
import {
  createAffine2D,
  createSpritePass,
  createTilemap,
  frameOf,
  namedSheet,
  setTile,
  worldToNdc,
} from '@driftengine/ui2d';
import type { SpriteBatch, SpriteSheet, Tilemap } from '@driftengine/ui2d';
import { patchModule } from 'driftscript';
import { createReadout } from '../common/readout';
import { exported, hostScript } from '../common/script';
import { controls, flag, openStage } from '../common/stage';
import * as gardenScript from './garden.drs';
import { DAY, DUSK, ENTRIES, SHEET_HEIGHT, SHEET_WIDTH, paintSheet } from './sheet';

const stage = await openStage({});
const { renderer, canvas } = stage;

// #region pass
/* One pass of sprites, drawn wherever in the frame it is asked for, over one sheet. A sheet of
   pixel art is filtered nearest, so its texels stay square at any zoom. */
const pass = createSpritePass({ capacity: 4096, slots: 1, label: 'garden' });
const handle = renderer.registerPass(pass);
const painted = new OffscreenCanvas(SHEET_WIDTH, SHEET_HEIGHT);
let palette = flag('light', 'day') === 'dusk' ? DUSK : DAY;
let filter: 'nearest' | 'linear' = flag('filter', 'nearest') === 'linear' ? 'linear' : 'nearest';
function upload(): void {
  pass.setTexture(0, paintSheet(painted, palette), { filter });
}
upload();
const sheet = namedSheet(0, SHEET_WIDTH, SHEET_HEIGHT, ENTRIES);
// #endregion

// #region map
/* The garden, a tile a world unit: grass and clover, a path, a pond, a fenced patch of soil, some
   stones, and flowers scattered over the grass. Row 0 is the bottom, because the world's y is up. */
const COLUMNS = 48;
const ROWS = 36;
const map = createTilemap(COLUMNS, ROWS, 1, 1);
const tileOf = (name: string): number => frameOf(sheet, name);
let seed = 7;
const random = (): number => {
  seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
  return seed / 0x100000000;
};
for (let row = 0; row < ROWS; row += 1) {
  for (let column = 0; column < COLUMNS; column += 1) {
    const pond = ((column - 34) / 7) ** 2 + ((row - 24) / 5) ** 2 < 1;
    const patch = column >= 8 && column <= 17 && row >= 6 && row <= 13;
    const edge = patch && (column === 8 || column === 17 || row === 6 || row === 13);
    const path = Math.abs(row - (17 + Math.sin(column / 5) * 3)) < 1;
    let name = random() < 0.2 ? 'clover' : 'grass';
    if (path) name = 'path';
    if (pond) name = 'water';
    if (patch) name = edge && !(row === 13 && column === 12) ? 'fence' : 'soil';
    if (name === 'grass' && random() < 0.05) name = 'flower';
    if (name === 'grass' && random() < 0.015) name = 'stone';
    setTile(map, column, row, tileOf(name));
  }
}
const flowers = (): number => {
  let count = 0;
  for (const tile of map.tiles) if (tile === tileOf('flower')) count += 1;
  return count;
};
const planted = flowers();
// #endregion

// #region script
const input = new InputSource(canvas, ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight']);
const actions = new ActionMap(input, {
  move: {
    stick: 'left',
    up: ['KeyW', 'ArrowUp'],
    down: ['KeyS', 'ArrowDown'],
    left: ['KeyA', 'ArrowLeft'],
    right: ['KeyD', 'ArrowRight'],
  },
});
const script = hostScript(gardenScript);
interface Gardener {
  x: number;
  y: number;
  picked: number;
}
const gardener = exported<() => Gardener>(script, 'createGardener')();
type Walk = (
  gardener: Gardener,
  actions: ActionMap,
  map: Tilemap,
  sheet: SpriteSheet,
  dt: number,
) => void;
type Draw = (
  gardener: Gardener,
  batch: SpriteBatch,
  map: Tilemap,
  sheet: SpriteSheet,
  x: number,
  y: number,
  w: number,
  h: number,
) => void;
if (import.meta.hot) {
  import.meta.hot.accept('./garden.drs', (next) => {
    if (next !== undefined) {
      patchModule(script, next as Record<string, unknown>, { Gardener: [gardener] });
    }
  });
}
// #endregion

let zoom = flag('zoom', 'near') === 'far' ? 28 : 56;
controls([
  {
    key: 'zoom',
    label: 'zoom',
    value: zoom === 28 ? 'far' : 'near',
    options: ['near', 'far'].map((z) => ({ text: z, value: z })),
    change: (value) => {
      zoom = value === 'far' ? 28 : 56;
    },
  },
  {
    key: 'filter',
    label: 'filter',
    value: filter,
    options: ['nearest', 'linear'].map((f) => ({ text: f, value: f })),
    change: (value) => {
      filter = value === 'linear' ? 'linear' : 'nearest';
      upload();
    },
  },
  {
    key: 'light',
    label: 'light',
    value: palette === DUSK ? 'dusk' : 'day',
    options: ['day', 'dusk'].map((l) => ({ text: l, value: l })),
    change: (value) => {
      palette = value === 'dusk' ? DUSK : DAY;
      upload();
    },
  },
]);

const affine = createAffine2D();
const view = { x: 0, y: 0, zoom: 56, rotation: 0 };
const readout = createReadout(renderer, 2);
let time = 0;

stage.run({
  simulate(dt) {
    time += dt;
    exported<Walk>(script, 'walk')(gardener, actions, map, sheet, dt);
  },
  render() {
    // #region frame
    /* The camera follows the gardener, and the view it sees, in tiles, is what the tilemap draws. */
    const width = canvas.clientWidth;
    const height = canvas.clientHeight;
    const seenW = width / zoom;
    const seenH = height / zoom;
    /* Kept inside the garden, so its edge is never in view. */
    view.x = Math.min(Math.max(gardener.x, seenW / 2), COLUMNS - seenW / 2);
    view.y = Math.min(Math.max(gardener.y, seenH / 2), ROWS - seenH / 2);
    view.zoom = zoom;
    pass.reset();
    pass.setTransform(worldToNdc(view, width, height, affine));
    exported<Draw>(script, 'draw')(
      gardener,
      pass.batch,
      map,
      sheet,
      view.x - seenW / 2,
      view.y - seenH / 2,
      seenW,
      seenH,
    );
    renderer.beginFrame(palette === DUSK ? [0.08, 0.1, 0.14] : [0.3, 0.45, 0.25]);
    renderer.drawPass(handle);
    // #endregion
    readout.set(
      0,
      `${gardener.picked} OF ${planted} FLOWERS PICKED  ${pass.batch.count} SPRITES THIS FRAME`,
    );
    readout.set(1, 'WASD, THE ARROWS OR A STICK TO WALK');
    readout.draw(time);
    renderer.endFrame();
  },
});

garden.drs

examples/sprites/garden.drs
// The gardener: walking the garden, kept out of the pond and off the fences and stones, picking the
// flowers it walks over, and drawn over the tilemap facing the way it walks.
//
// Under `npm run examples`, change a rule and save: the gardener walks by it from the next tick.
// Try a faster gardener, one who can wade, or flowers that leave soil where they were picked.

import { frame, named, setTile, tile, tilemap } from "drift/2d" as sprites
import { axisX, axisY } from "drift/input"
import { abs, floor } from "std/math"

// The way the gardener faces, which picks its row of steps on the sheet.
enum Facing {
    Down
    Up
    Left
    Right
}

// Where a way of facing starts on the sheet: two steps a row, from `down0`.
fn row(facing: Facing) -> i32 {
    return match facing {
        Down => 0
        Up => 2
        Left => 4
        Right => 6
    }
}

data Gardener {
    // Where its feet are, in tiles, and the world's y is up.
    x: f32 = 24.5
    y: f32 = 17.5
    facing: Facing = Facing.Down
    // How far it has walked, which picks the step.
    stride: f32 = 0
    speed: f32 = 4
    picked: u32 = 0
}

// #region walk
// Steered by the move action, an axis at a time, so it slides along a fence it walks into.
fn walk(g: mut Gardener, actions: Actions, map: Tilemap, sheet: SpriteSheet, dt: f32) {
    let dx = input.axisX(actions, "move")
    // A stick's up is negative, and the garden's is positive.
    let dy = -input.axisY(actions, "move")
    if math.abs(dx) > math.abs(dy) {
        if dx > 0 {
            g.facing = Facing.Right
        } else {
            g.facing = Facing.Left
        }
    } else if dy > 0 {
        g.facing = Facing.Up
    } else if dy < 0 {
        g.facing = Facing.Down
    }
    let nx = g.x + dx * g.speed * dt
    let ny = g.y + dy * g.speed * dt
    if open(map, sheet, nx, g.y) {
        g.x = nx
    }
    if open(map, sheet, g.x, ny) {
        g.y = ny
    }
    g.stride += (math.abs(dx) + math.abs(dy)) * g.speed * dt
    pick(g, map, sheet)
}
// #endregion

// Whether the gardener may stand here: not in water, and not on a fence or a stone.
fn open(map: Tilemap, sheet: SpriteSheet, x: f32, y: f32) -> bool {
    let here = sprites.tile(map, cell(x), cell(y))
    return here != sprites.named(sheet, "water") && here != sprites.named(sheet, "fence") && here != sprites.named(sheet, "stone")
}

fn cell(at: f32) -> i32 {
    return i32.clamp(math.floor(at))
}

// #region pick
// A flower underfoot is picked: its tile becomes plain grass, and the count goes up.
fn pick(g: mut Gardener, map: Tilemap, sheet: SpriteSheet) {
    let column = cell(g.x)
    let row = cell(g.y)
    if sprites.tile(map, column, row) == sprites.named(sheet, "flower") {
        sprites.setTile(map, column, row, sprites.named(sheet, "grass"))
        g.picked += 1
    }
}
// #endregion

// #region draw
// The tilemap, only what the view holds, then the gardener over it: its row by the way it faces,
// and one of two steps by how far it has walked.
fn draw(g: Gardener, batch: SpriteBatch, map: Tilemap, sheet: SpriteSheet, viewX: f32, viewY: f32, viewW: f32, viewH: f32) {
    sprites.tilemap(batch, map, sheet, viewX, viewY, viewW, viewH)
    // Three steps a tile, alternating feet.
    let pace = g.stride * 3
    var step: i32 = 0
    if pace - math.floor(pace / 2) * 2 >= 1 {
        step = 1
    }
    let first = sprites.named(sheet, "down0")
    sprites.frame(batch, sheet, first + row(g.facing) + step, g.x - 0.5, g.y - 0.15, 1, 1)
}
// #endregion

sheet.ts

examples/sprites/sheet.ts
/**
 * The garden's sprite sheet, painted pixel by pixel so the example loads no image.
 *
 * Sixteen cells of sixteen texels: eight tiles in the top row, and the gardener in the bottom one,
 * two steps for each way it can face. A palette paints it, so the same sheet comes out by day or at
 * dusk without a second picture.
 */
import type { SheetEntry } from '@driftengine/ui2d';

export const CELL = 16;
export const SHEET_WIDTH = CELL * 8;
export const SHEET_HEIGHT = CELL * 2;

const TILES = ['grass', 'clover', 'path', 'water', 'flower', 'fence', 'stone', 'soil'];
const STEPS = ['down0', 'down1', 'up0', 'up1', 'left0', 'left1', 'right0', 'right1'];

/** Every cell by name, where it is on the sheet. */
export const ENTRIES: readonly SheetEntry[] = [
  ...TILES.map((name, at) => ({ name, x: at * CELL, y: 0, w: CELL, h: CELL })),
  ...STEPS.map((name, at) => ({ name, x: at * CELL, y: CELL, w: CELL, h: CELL })),
];

export type Palette = Record<
  | 'grass'
  | 'grassDark'
  | 'path'
  | 'water'
  | 'waterLight'
  | 'petal'
  | 'wood'
  | 'stone'
  | 'soil'
  | 'skin'
  | 'shirt'
  | 'hat',
  string
>;

export const DAY: Palette = {
  grass: '#5c9a3c',
  grassDark: '#4a8530',
  path: '#c8a56a',
  water: '#3a78c4',
  waterLight: '#7fb2ea',
  petal: '#f2d24b',
  wood: '#8a5a32',
  stone: '#8d8f94',
  soil: '#6b4a2e',
  skin: '#f0c49a',
  shirt: '#c8463c',
  hat: '#e9d27a',
};

export const DUSK: Palette = {
  grass: '#2f5a3a',
  grassDark: '#264b31',
  path: '#7c6a55',
  water: '#22406e',
  waterLight: '#4c6fa3',
  petal: '#e8a24b',
  wood: '#5a3c2a',
  stone: '#5c5e6a',
  soil: '#3e2c22',
  skin: '#c79a80',
  shirt: '#8e3a3e',
  hat: '#b49c66',
};

/** Paint the sheet into a canvas, with a palette. */
export function paintSheet(canvas: OffscreenCanvas, palette: Palette): OffscreenCanvas {
  const g = canvas.getContext('2d');
  if (g === null) throw new Error('no 2d context for the sprite sheet');
  g.clearRect(0, 0, SHEET_WIDTH, SHEET_HEIGHT);
  const px = (cell: number, row: number, x: number, y: number, w: number, h: number, c: string) => {
    g.fillStyle = c;
    g.fillRect(cell * CELL + x, row * CELL + y, w, h);
  };
  /* Tiles, top row. Grass with a scatter of darker blades. */
  for (const cell of [0, 1, 4]) {
    px(cell, 0, 0, 0, CELL, CELL, palette.grass);
    for (const [x, y] of [
      [2, 3],
      [9, 2],
      [13, 7],
      [5, 10],
      [11, 13],
      [1, 12],
    ] as const) {
      px(cell, 0, x, y, 1, 2, palette.grassDark);
    }
  }
  /* Clover: three small leaves. */
  for (const [x, y] of [
    [6, 6],
    [8, 6],
    [7, 8],
  ] as const) {
    px(1, 0, x, y, 2, 2, palette.grassDark);
  }
  px(2, 0, 0, 0, CELL, CELL, palette.path);
  px(2, 0, 3, 4, 2, 1, palette.soil);
  px(2, 0, 10, 11, 2, 1, palette.soil);
  px(3, 0, 0, 0, CELL, CELL, palette.water);
  px(3, 0, 2, 4, 5, 1, palette.waterLight);
  px(3, 0, 9, 10, 5, 1, palette.waterLight);
  /* A flower on grass: a stem and four petals round a centre. */
  px(4, 0, 7, 8, 2, 5, palette.grassDark);
  px(4, 0, 5, 4, 6, 4, palette.petal);
  px(4, 0, 7, 3, 2, 6, palette.petal);
  px(4, 0, 7, 5, 2, 2, palette.wood);
  /* A fence: two posts and two rails, on grass. */
  px(5, 0, 0, 0, CELL, CELL, palette.grass);
  px(5, 0, 2, 2, 3, 13, palette.wood);
  px(5, 0, 11, 2, 3, 13, palette.wood);
  px(5, 0, 0, 5, CELL, 2, palette.wood);
  px(5, 0, 0, 10, CELL, 2, palette.wood);
  px(6, 0, 0, 0, CELL, CELL, palette.grass);
  px(6, 0, 3, 4, 10, 9, palette.stone);
  px(6, 0, 4, 3, 7, 2, palette.stone);
  px(7, 0, 0, 0, CELL, CELL, palette.soil);
  px(7, 0, 0, 4, CELL, 1, palette.wood);
  px(7, 0, 0, 11, CELL, 1, palette.wood);

  /* The gardener, bottom row: a hat, a face, a shirt and two legs, which swap on the second step.
     Facing down shows both eyes, up shows none, and a side shows one eye on that side. */
  for (let facing = 0; facing < 4; facing += 1) {
    for (let step = 0; step < 2; step += 1) {
      const cell = facing * 2 + step;
      px(cell, 1, 4, 1, 8, 3, palette.hat);
      px(cell, 1, 3, 3, 10, 1, palette.hat);
      px(cell, 1, 5, 4, 6, 4, palette.skin);
      if (facing === 0) {
        px(cell, 1, 6, 5, 1, 1, '#222');
        px(cell, 1, 9, 5, 1, 1, '#222');
      } else if (facing === 2) {
        px(cell, 1, 5, 5, 1, 1, '#222');
      } else if (facing === 3) {
        px(cell, 1, 10, 5, 1, 1, '#222');
      }
      px(cell, 1, 4, 8, 8, 5, palette.shirt);
      const lift = step === 0 ? 0 : 1;
      px(cell, 1, 5, 13, 2, 3 - lift, palette.wood);
      px(cell, 1, 9, 13 + lift, 2, 3 - lift, palette.wood);
    }
  }
  return canvas;
}