Lifecycle and time
willow runs two clocks. Frames happen once per screen refresh. Ticks happen at a fixed rate, 60 per second by default (App.tps, or tps in thicket.json). Put game logic on ticks so it runs the same on every machine, and put purely visual work on frames.
App-wide hooks
| Hook | Runs |
|---|---|
App.onTick(fn) | Once per tick. After a slow frame, several ticks run to catch up (at most App.maxCatchUpTicks, default 8). |
App.onFrame(fn) | Once per drawn frame, with dt in seconds. |
App.onResize(fn) | When the window changes size. |
App.onFocus(fn) / App.onBlur(fn) | When the window gains or loses focus. |
App.onQuit(fn) | When the app is closing. |
Each returns a function that unsubscribes it. Useful readings: App.tick (ticks so far), App.time (scaled seconds), App.realTime (wall seconds), App.actualFps, App.actualTps.
Node hooks
Any node can carry its own hooks. Assign a function; assign null to remove it.
| Hook | Runs |
|---|---|
onEnter() | When the node is added to a parent. |
onExit() | When it is removed from its parent. |
onReady() | Once, on the first frame the node is in the tree. |
onTick() | Once per tick, parents before children. |
onUpdate(dt) | Every frame, after onReady. |
onLateUpdate(dt) | Every frame, after every node's onUpdate and the tweens. Good for cameras and things that follow other things. |
ts
import { Sprite } from "willow";
class Enemy {
static spawn(x: float, y: float): Sprite {
const e: Sprite = new Sprite("assets/slime.png");
e.setAnchor(0.5, 1.0);
e.setPosition(x, y);
e.interpolate = true;
let dir: float = 1;
e.onReady = (): void => {
console.log("slime ready at " + e.x);
};
e.onTick = (): void => {
e.x += 2 * dir;
if (e.x > x + 100 || e.x < x - 100) {
dir = -dir;
e.scaleX = dir;
}
};
return e;
}
static main(): void {
engine.root2d.add(Enemy.spawn(200, 400));
engine.root2d.add(Enemy.spawn(500, 400));
}
}A hook that throws is reported once and then turned off, so one bad enemy does not flood the log.
Interpolation
Move a node in onTick and it moves in steps of 1/60 second. On a 144 Hz screen that looks jerky. Set interpolate = true and willow draws x, y, rotation, scaleX and scaleY blended between the last two ticks. Your code still reads the real values.
After teleporting an interpolated node, call resetInterpolation(), or it visibly slides from the old spot for one tick.
Pausing
paused = true on a node stops its subtree's onTick, onUpdate, onLateUpdate, ticker and tweens. Drawing continues, so the game freezes in place. pauseMode lets parts of a paused tree keep going:
PauseMode | Behaviour |
|---|---|
INHERIT | Follows its parent (default). |
ALWAYS | Keeps running while an ancestor is paused: a pause menu inside the game tree. |
WHEN_PAUSED | Runs only while an ancestor is paused. |
DISABLED | Never runs. |
ts
import { Node, Text, PauseMode } from "willow";
class PauseDemo {
static main(): void {
const game: Node = new Node("game");
engine.root2d.add(game);
const menu: Text = new Text("Paused");
menu.visible = false;
menu.pauseMode = PauseMode.WHEN_PAUSED;
menu.onUpdate = (dt: float): void => {
menu.alpha = 0.6 + 0.4 * Math.sin(App.realTime * 4);
};
game.add(menu);
App.onFrame((dt: float): void => {
if (Keyboard.isPressed(Key.ESCAPE)) {
game.paused = !game.paused;
menu.visible = game.paused;
}
});
}
}Time scale
node.timeScale speeds up or slows down a subtree: 0.5 is half speed (slow motion), 2 is double. It scales dt for onUpdate and onLateUpdate, the number of onTick calls, and the node's ticker. App.timeScale does the same for the whole app. A node with unscaled = true ignores both, which suits UI that should not slow down with the game.
Next
- Input and hit testing
- Reference: Node