Skip to content

Input and hit testing ​

There are two ways to read input:

  • Global state for game controls: Keyboard, Mouse, Gamepad, TouchInput and InputMap. They are built in, with nothing to import.
  • Node handlers for things you point at: buttons, cards, units. willow finds the node under the pointer and calls its handler.

Global input ​

ts
import { Sprite } from "willow";

class Controls {
    static main(): void {
        const ship: Sprite = new Sprite("assets/ship.png");
        ship.setAnchor(0.5, 0.5);
        ship.setPosition(400, 300);
        engine.root2d.add(ship);

        App.onFrame((dt: float): void => {
            let dx: float = Gamepad.axis(0, GamepadAxis.LEFT_X);
            if (Keyboard.isDown(Key.LEFT)) {
                dx = -1;
            }
            if (Keyboard.isDown(Key.RIGHT)) {
                dx = 1;
            }
            ship.x += dx * 300 * dt;

            if (Keyboard.isPressed(Key.SPACE) || Gamepad.isPressed(0, GamepadButton.A)) {
                console.log("fire");
            }
            if (Mouse.isPressed(Mouse.LEFT)) {
                console.log("clicked at " + Mouse.x + ", " + Mouse.y);
            }
        });
    }
}

isDown is true while held; isPressed and isReleased are true on the frame (or tick) the state changes. Mouse.x and Mouse.y are in screen pixels; convert them to the world with engine.camera.screenToWorld.

Node handlers ​

Assign a handler and the node takes part in hit testing. A node with no pointer handler is never hit.

HandlerPayloadRuns when
onPointerDownPointerEventA button goes down over the node.
onPointerUpPointerEventA button goes up over the node.
onClickPointerEventDown and up both land on the node.
onPointerMovePointerEventThe pointer moves over the node.
onPointerEnter / onPointerLeavePointerEventThe pointer enters or leaves the node. These do not bubble.
onDragStart, onDrag, onDragEndDragEventA press moves past the drag dead zone, keeps moving, then lets go.
onWheelWheelEventThe wheel or trackpad scrolls over the node.
onPinchPinchContextA two-finger pinch or rotate.
ts
import { Quad, Text, PointerEvent, KeyModifiers } from "willow";

class ButtonDemo {
    static main(): void {
        const button: Quad = new Quad(160, 48, Color.hex("#3366CC"));
        button.setPosition(320, 276);
        engine.root2d.add(button);

        const label: Text = new Text("Start");
        label.setPosition(56, 12);
        button.add(label);

        button.onPointerEnter = (e: PointerEvent): void => {
            button.color = Color.hex("#4A7FE6");
        };
        button.onPointerLeave = (e: PointerEvent): void => {
            button.color = Color.hex("#3366CC");
        };
        button.onClick = (e: PointerEvent): void => {
            const shift: boolean = (e.modifiers & KeyModifiers.SHIFT) !== 0;
            console.log("start, shift held: " + shift + ", at " + e.x + ", " + e.y);
        };
    }
}

A PointerEvent carries the point in the node's local space (x, y), in the world (worldX, worldY) and on screen (screenX, screenY), plus button (0 left, 1 right, 2 middle), modifiers and target.

interactable = false takes a node out of hit testing while keeping its handlers, which is how you disable a button.

Dragging ​

ts
import { Sprite, DragEvent, Pointer } from "willow";

class DragCard {
    static main(): void {
        const card: Sprite = new Sprite("assets/card.png");
        card.setPosition(200, 200);
        engine.root2d.add(card);

        let startX: float = 0;
        let startY: float = 0;
        card.onDragStart = (e: DragEvent): void => {
            startX = card.x;
            startY = card.y;
            card.alpha = 0.8;
        };
        card.onDrag = (e: DragEvent): void => {
            card.x = startX + e.deltaX;
            card.y = startY + e.deltaY;
        };
        card.onDragEnd = (e: DragEvent): void => {
            card.alpha = 1;
        };

        Pointer.setDragDeadZone(6);
    }
}

deltaX and deltaY are the world distance since the drag began. The drag dead zone (Pointer.setDragDeadZone(px)) is how far a press must move before it counts as a drag rather than a click.

Pointer.capture(node) sends every pointer event to one node, wherever the pointer goes, until Pointer.release(). Use it for sliders and scroll bars.

Hit shapes ​

By default the hit area is the node's bounding box. Give a node a tighter shape in its local space:

CallHit area
setHitRect(x, y, w, h)A rectangle.
setHitCircle(cx, cy, r)A circle.
setHitPolygon([x0, y0, ...])A convex polygon, at least three points.
clearHitShape()Back to the bounding box.
ts
import { Quad } from "willow";

class HitShapes {
    static main(): void {
        const coin: Quad = new Quad(64, 64, Color.WHITE);
        coin.setHitCircle(32, 32, 30);
        console.log("center: " + coin.hitShapeContains(32, 32));
        console.log("corner: " + coin.hitShapeContains(2, 2));
        coin.clearHitShape();
        console.log("custom shape after clear: " + coin.hasHitShape());
    }
}

Output:

text
center: true
corner: false
custom shape after clear: false

Masks do not change hit testing. A node clipped by a mask is still hit over its full shape.

Clicks on empty space and keys as events ​

engine.scene.onBackgroundClick(fn) runs when a click misses every node, which is handy for deselecting. engine.scene.onKeyDown(fn) and onKeyUp(fn) deliver each key change as a KeyEvent with keyCode (a Key constant), label, charCode, repeat and the modifier flags, in the order they arrived.

Next ​