Skip to content

Camera and layers ​

The scene has one 2D camera, engine.camera (also Scene.main.camera). It decides which part of the world root you see. Nodes under the screen root (engine.screen2d) ignore it.

At the start, world (0, 0) is the top-left of the window. The camera's x and y are the world point at the center of the view.

Moving the camera ​

MemberDoes
x, y, positionThe world point the view is centered on.
zoom1 is normal, above 1 zooms in, below 1 zooms out.
rotationRotation in radians.
boundsA world Rect the view stays inside, or null.
follow(node, { offset, lerp })Track a node every frame. Leave out lerp to snap; a small lerp such as 0.1 follows smoothly.
unfollow()Stop following and stay put.
scrollTo(point, seconds, ease?)Glide to a point. Returns a promise.
scrollToTile(col, row, seconds, ease?, tileSize?)Glide to the center of a tile (32x32 by default).
shake(trauma, decay?)Shake. trauma 0 to 1 adds up across hits; the shake grows with its square and fades by decay per second.
screenToWorld(p), worldToScreen(p)Convert points.
visibleBounds()The world Rect currently on screen.
ts
import { Sprite, Quad, Text, Camera2D } from "willow";

class Follow {
    static main(): void {
        const level: Quad = new Quad(3200, 600, Color.hex("#24395C"));
        engine.root2d.add(level);

        const hero: Sprite = new Sprite("assets/hero.png");
        hero.setAnchor(0.5, 1.0);
        hero.setPosition(100, 500);
        engine.root2d.add(hero);

        const cam: Camera2D = engine.camera;
        cam.follow(hero, { offset: Vec2{ x: 0.0, y: -80.0 }, lerp: 0.1 });
        cam.bounds = Rect{ x: 0.0, y: 0.0, width: 3200.0, height: 600.0 };

        const hud: Text = new Text("Distance: 0");
        hud.setPosition(16, 16);
        engine.screen2d.add(hud);

        App.onFrame((dt: float): void => {
            if (Keyboard.isDown(Key.RIGHT)) {
                hero.x += 300 * dt;
            }
            if (Keyboard.isPressed(Key.X)) {
                cam.shake(0.4);
            }
            hud.text = "Distance: " + Math.round(hero.x);
        });
    }
}

The HUD text is under engine.screen2d, so it stays in the corner while the world scrolls.

scrollTo and follow interrupt each other: starting one rejects the promise of a scroll that was still running.

Culling ​

cam.cullEnabled = true skips drawing any node whose world bounds are outside the view. It helps when a large world has many nodes off screen. Large tile grids have their own culling; see Tilemaps.

Placing screen nodes over world points ​

A health bar over a unit should stay the same size when the camera zooms, so it belongs on the screen root. Each frame, put it where the unit is on screen:

ts
import { Sprite, Quad } from "willow";

class Overhead {
    static main(): void {
        const unit: Sprite = new Sprite("assets/unit.png");
        unit.setPosition(600, 400);
        engine.root2d.add(unit);

        const bar: Quad = new Quad(40, 6, Color.hex("#4CD964"));
        engine.screen2d.add(bar);

        unit.onLateUpdate = (dt: float): void => {
            const p: Vec2 = unit.toScreen(0, -10);
            bar.setPosition(p.x, p.y);
        };
    }
}

onLateUpdate runs after the camera has moved for this frame.

Layers ​

Render layers decide draw order across the whole tree; see The scene graph. A common setup:

LayerValueHolds
LAYER_BACKGROUND0Sky, parallax backdrops
LAYER_DEFAULT64The world
LAYER_UI192HUD and menus
LAYER_OVERLAY255Fades, debug overlays

Read them from engine.scene.

Finding nodes: NodeIndex ​

A NodeIndex finds nodes by name or tag, so you don't have to keep your own lists. The scene has one, engine.scene.index, and you can make your own with new NodeIndex().

ts
import { Node, NodeIndex } from "willow";

class Lookup {
    static main(): void {
        const index: NodeIndex = new NodeIndex();
        const bat: Node = new Node("bat-1");
        const slime: Node = new Node("slime-1");
        index.add(bat, "enemy", "flying");
        index.add(slime, "enemy");

        console.log("enemies: " + index.countByTag("enemy"));
        console.log("flying enemies: " + index.findByTags("enemy", "flying").length);
        console.log("bats: " + index.findAllByName("bat-%").length);

        slime.dispose();
        console.log("enemies after one is disposed: " + index.countByTag("enemy"));
    }
}

Output:

text
enemies: 2
flying enemies: 1
bats: 1
enemies after one is disposed: 1

% in a pattern matches anything, as in SQL LIKE. A disposed node leaves the index on its own.

Next ​