Skip to content

Common pitfalls ​

These are the things that trip people up most often in willow. Each one is short, says what happens, and says what to do instead.

Rotation is in radians ​

rotation, skewX, skewY and setRotation() take radians, not degrees. node.rotation = 90 turns the node about 14 full times, which looks like nothing much happened.

ts
node.rotation = Math.PI / 2;          // a quarter turn, clockwise on screen
node.rotation = 45 * Math.PI / 180;   // from degrees

The pivot is in pixels, the anchor is a fraction ​

pivotX, pivotY and setPivot(x, y) are in the node's local pixels. setAnchor(nx, ny) takes a fraction of the node's size and sets the pivot for you. Mixing them up is the usual reason a sprite spins around its corner.

ts
import { Quad } from "willow";

class Pivots {
    static main(): void {
        const q: Quad = new Quad(100, 50, Color.WHITE);
        q.setAnchor(0.5, 0.5);
        console.log("anchor 0.5 -> pivot " + q.pivotX + ", " + q.pivotY);
        q.setPivot(0.5, 0.5);
        console.log("setPivot 0.5 -> pivot " + q.pivotX + ", " + q.pivotY);
    }
}

Output:

text
anchor 0.5 -> pivot 50, 25
setPivot 0.5 -> pivot 0.5, 0.5

To centre a node, use setAnchor(0.5, 0.5) or alignPivot(). See Nodes and transforms.

Alpha and tint multiply ​

A node's alpha multiplies with its parent's alpha, all the way up the tree. A sprite's tint is a separate multiplier on top, and its alpha counts too. So a sprite at alpha = 1 can still be invisible if its parent is at 0, or if its tint has an alpha of 0. Use alpha to fade, and tint to colour.

A raw number used as a colour is always 0xRRGGBBAA. A six-digit number such as 0xFF0000 has an alpha of 0. Write all eight digits, or use Color.hex("#FF0000"), which treats a six-digit string as opaque. See Shapes and meshes.

add() moves a node ​

A node has one parent. Adding it somewhere else takes it out of the old parent first; it is not copied.

ts
import { Node } from "willow";

class Moves {
    static main(): void {
        const a: Node = new Node("a");
        const b: Node = new Node("b");
        const coin: Node = new Node("coin");
        a.add(coin);
        b.add(coin);
        console.log("a has coin: " + a.contains(coin) + ", b has coin: " + b.contains(coin));
    }
}

Output:

text
a has coin: false, b has coin: true

To show the same thing twice, make two nodes.

remove() is reversible, dispose() is not ​

remove() detaches a node and keeps it, so you can add it back. dispose() frees it for good, and every node below it. Any later use of a disposed node, even reading x, raises InvalidStateError.

ts
import { Node } from "willow";

class Gone {
    static main(): void {
        const enemy: Node = new Node("enemy");
        const healthBar: Node = new Node("healthBar");
        enemy.add(healthBar);
        enemy.dispose();
        try {
            console.log(healthBar.x);
        } catch (e) {
            console.log("" + e);
        }
    }
}

Output:

text
InvalidStateError: Node.x: node has been disposed

Drop your own references to a node when you dispose it, and dispose the parent rather than each child. Disposing twice is safe.

A node with no handler is not hit ​

A node takes pointer events only while it has a pointer handler assigned (onClick, onPointerDown, onDrag and the rest). A node without one is not hit tested at all, so the click goes to whatever is under it. interactable defaults to true; setting it to false takes a node out of hit testing while keeping its handlers, which is how you disable a button.

So if clicks on a panel's background reach the world behind it, give the panel a handler, even an empty one. See Input and hit testing.

Masks clip drawing, not input ​

setMask() changes what is drawn. It does not change hit testing, so the hidden part of a masked node can still be clicked. If that matters, give the node a hit shape that matches the visible area with setHitRect or setHitCircle. See Effects.

Hooks are single slots ​

onUpdate, onTick, onClick and the other handlers are fields. Assigning a second function replaces the first; it does not add to it. To run two things, call both from one function, or use named events with on(name, handler), which keep a list. Assign null to detach a handler.

A lifecycle hook (onUpdate, onTick and the rest) that throws is reported once and then turned off, so a node that suddenly stops moving may have thrown on an earlier frame. Check the log.

Names are labels, not keys ​

A node's name is for debugging and lookup. Nothing stops two nodes having the same name, and a NodeIndex keeps them all: findByName returns the first one registered, while findAllByName and each see every one. To find a group, tag the nodes and use findByTag. See Camera and layers.

The camera's position is the centre of the view ​

You do not need to set up a camera: with an untouched camera, world (0, 0) is the top-left of the window. When you do move it, camera.x and camera.y are the world point at the centre of the screen, not the top-left corner. follow(node) with no lerp snaps to the node; a small lerp such as 0.1 eases toward it. See Camera and layers.

HUDs belong on the screen root ​

Anything under engine.root2d is drawn through the camera, so it scrolls and zooms with the world. Put menus, scores and health bars under engine.screen2d instead. See The scene graph.

Next ​