Skip to content

Sprites and textures ​

A Texture is an image on the GPU. A TextureRegion is a rectangle inside a texture. A Sprite is a node that draws a texture or a region.

Sprites ​

Sprite has three constructors:

ConstructorUse it when
new Sprite("assets/hero.png")One sprite uses the image. The sprite loads it.
new Sprite(texture)Several sprites share one loaded texture.
new Sprite(region)The sprite shows one frame or tile of a larger image.
ts
import { Sprite, Texture } from "willow";

class Coins {
    static main(): void {
        const coinTex: Texture = Texture.load("assets/coin.png");

        for (let i: int = 0; i < 5; i++) {
            const coin: Sprite = new Sprite(coinTex);
            coin.setAnchor(0.5, 0.5);
            coin.x = 100 + i * 80;
            coin.y = 200;
            engine.root2d.add(coin);
        }

        const ghost: Sprite = new Sprite(coinTex);
        ghost.x = 100;
        ghost.y = 300;
        ghost.tint = Color.hex("#80C0FF");
        ghost.alpha = 0.5;
        engine.root2d.add(ghost);
    }
}

tint multiplies the image's colours. Color.WHITE (the default) leaves it unchanged. width and height read the size of the region being drawn, before scale.

To change what a sprite shows, call setTexture(texture) or setRegion(region).

Loading needs the fs permission in thicket.json. Texture.load caches by path, so loading the same file twice returns the same image; pass { cache: false } to load a fresh copy.

Textures from other sources ​

CallMakes a texture from
Texture.load(path)A PNG or JPEG file.
Texture.fromBuffer(buffer)PNG or JPEG bytes already in memory.
Texture.fromPixels(w, h, rgba, updatable)Raw RGBA bytes, w * h * 4 of them. Pass updatable = true to replace them later with update(rgba).

Call dispose() on a texture you no longer need to free the GPU memory.

Regions and tilesets ​

TextureRegion.fromRect(texture, x, y, w, h) cuts out one rectangle. TextureRegion.grid(texture, tileW, tileH) slices a whole tileset into an array, row by row:

ts
import { Sprite, Texture, TextureRegion } from "willow";

class Tiles {
    static main(): void {
        const sheet: Texture = Texture.load("assets/tileset.png");
        const tiles: Array<TextureRegion> = TextureRegion.grid(sheet, 32, 32);

        const grass: Sprite = new Sprite(tiles[0]);
        grass.x = 0;
        engine.root2d.add(grass);

        const water: Sprite = new Sprite(tiles[5]);
        water.x = 32;
        engine.root2d.add(water);

        const door: TextureRegion = TextureRegion.fromRect(sheet, 64, 96, 32, 64);
        const doorSprite: Sprite = new Sprite(door);
        doorSprite.x = 64;
        engine.root2d.add(doorSprite);
    }
}

grid takes an optional fourth argument { columns, count, margin, spacing } for tilesets with gaps between cells.

Atlases ​

An Atlas is a set of named regions on one texture page. Sprites that share a page can draw in one batch (see Performance).

FactoryHow the atlas is built
Atlas.load(json, texture)From a TexturePacker-style JSON manifest and its page texture.
Atlas.create(config?)Empty; pack images in one at a time with add(name, w, h, rgba).
Atlas.createBatch(config?)Empty; stage every image, then pack() once for a tighter fit.

PackerConfig sets the page size (2048x2048 by default) and the padding between images (1 pixel).

ts
import { Sprite, Texture, Atlas } from "willow";
import { Files } from "fs";

class Packed {
    static main(): void {
        const page: Texture = Texture.load("assets/sprites.png");
        const json: string = Files.readText("assets/sprites.json");
        const atlas: Atlas = Atlas.load(json, page);

        const hero: Sprite = new Sprite(atlas.region("hero_idle"));
        hero.x = 200;
        hero.y = 200;
        engine.root2d.add(hero);

        if (!atlas.has("hero_jump")) {
            console.log("hero_jump is missing from the atlas");
        }
    }
}

region(name) returns a small magenta placeholder for a missing name, so a typo shows up on screen instead of crashing. Check with has(name) when the name comes from data.

The atlaspack tool (thicket tool atlaspack) packs a folder of images into an atlas ahead of time.

Thousands of sprites: SpriteBatch ​

A Sprite is a full node: it can have children, filters and input handlers. When you have thousands of things that only move, scale, rotate or tint (bullets, stars, debris), a SpriteBatch is much cheaper. Its elements are a few numbers each, not nodes, and they all share the batch's one texture.

ts
import { SpriteBatch, Texture } from "willow";

class Starfield {
    static main(): void {
        const stars: SpriteBatch = new SpriteBatch(Texture.load("assets/star.png"));
        engine.root2d.add(stars);

        const handles: Array<float> = [];
        const speeds: Array<float> = [];
        for (let i: int = 0; i < 2000; i++) {
            handles.push(stars.addSprite(Math.random() * 800, Math.random() * 600));
            speeds.push(20 + Math.random() * 80);
        }

        App.onFrame((dt: float): void => {
            for (let i: int = 0; i < handles.length; i++) {
                const h: float = handles[i];
                const angle: float = App.time * 0.1 + i;
                stars.setSpritePosition(h, (i * 37) % 800, (App.time * speeds[i] + i * 13) % 600);
                stars.setSpriteRotation(h, angle);
            }
        });
    }
}

addSprite returns a handle. Pass it to setSpritePosition, setSpriteScale, setSpriteRotation, setSpriteAlpha, setSpriteTint and setSpriteRegion, or remove the element with removeSprite(handle). A removed handle stays dead: it is rejected rather than pointing at another element. setSpritePositions(flatArray) moves every element in one call.

Elements rotate and scale around their top-left corner.

Frame animation ​

An AnimationPlayer swaps a sprite's region over time. Give it named **AnimationSequence**s and play one by name:

ts
import { Sprite, Texture, TextureRegion, AnimationPlayer, AnimationSequence } from "willow";

class Walker {
    static main(): void {
        const frames: Array<TextureRegion> = TextureRegion.grid(Texture.load("assets/hero.png"), 32, 32);

        const hero: Sprite = new Sprite(frames[0]);
        hero.setAnchor(0.5, 1.0);
        hero.x = 400;
        hero.y = 400;
        engine.root2d.add(hero);

        const anim: AnimationPlayer = new AnimationPlayer(hero);
        anim.addSequence(new AnimationSequence("idle", [frames[0], frames[1]], 2, true));
        anim.addSequence(new AnimationSequence("walk", [frames[2], frames[3], frames[4], frames[5]], 10, true));
        anim.play("idle");

        App.onFrame((dt: float): void => {
            if (Keyboard.isDown(Key.RIGHT)) {
                hero.x += 120 * dt;
                hero.scaleX = 1;
                anim.play("walk");
            } else if (Keyboard.isDown(Key.LEFT)) {
                hero.x -= 120 * dt;
                hero.scaleX = -1;
                anim.play("walk");
            } else {
                anim.play("idle");
            }
        });
    }
}
  • play(name) does nothing if that sequence is already playing, so calling it every frame is fine.
  • A player whose node is in the scene is advanced by the scene each frame. If you call update(dt) yourself, you take over and the scene stops advancing that player.
  • onComplete(fn) runs when a non-looping sequence reaches its last frame.
  • scaleX = -1 mirrors the sprite. With the anchor at the center, it flips in place.

Next ​