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.
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)
}
// #endregionsheet.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;
}