-
Notifications
You must be signed in to change notification settings - Fork 0
Concepts
The full reference for everything runEngine/nextState support, past
the quick start in the README.
Most of these also have a small, runnable example in the
playground.
Declarative shapes drawn each frame: RECTANGLE, CIRCLE, TEXT,
SPRITE, LINE, GROUP, and ANIMATED_SPRITE. Give one an id plus
isClickable / isHoverable / trackMouseMovement to make it
interactive. TEXT also takes a fontSize (defaults to 30).
GROUP draws nothing itself — it's just a position (plus the usual
scale/modulate/layer below) for children to hang off of, for
grouping renderables that should move/scale/tint together without
needing a shape of its own; it's never interactable, since it has no
shape to hit-test.
ANIMATED_SPRITE is SPRITE with an animation (name of one defined on
that resource — see Sprites) and an optional timeScale
instead of a frame number; the engine picks the frame for you based on
how long it's been playing. Unlike every other renderable, its id is
required — that's how the engine recognizes "this is the same sprite as
last frame" across renders (and switches to frame 0 if animation
changes for that id), so reusing an id across two different entities
will make their animations bleed together.
Every renderable also takes:
-
layer— higher values render later, i.e. in front of lower ones. Defaults to0; renderables on the same layer keeprender()'s order. -
scale—{ x, y }multiplier on the renderable's size, anchored at itsposition(orfrom, forLINE). Defaults to{ x: 1, y: 1 }. ACIRCLEscaled unevenly draws (and hit-tests) as an ellipse. -
modulate— a CSS color string that multiplies the renderable's color channel-by-channel, the same way Godot'smodulateworks — e.g."#808080"halves brightness,"#ff0000"keeps only the red channel. -
children— nested renderables, positioned relative to this one, like Godot's parent/child nodes. A child'spositionis added to its parent's (scaled by the parent's ownscale), andscale/modulate/layerall compose down the tree — a child's effective scale is the parent's times its own,modulatemultiplies the same way, andlayeradds (relative to the parent's, matching Godot's default: a deeply-nested child can still end up drawn in front of an unrelated top-level renderable if its accumulated layer says so). A child is a fullRenderable, so it can have its ownid/isClickable/ even its ownchildren.screenSpace: truemakes a renderable (and its whole subtree) ignore the camera and stay fixed to the screen — for UI/HUD that shouldn't pan or zoom with the game world.
Pass camera: (state) => ({ x, y, zoom }) to runEngine to pan/zoom
every world-space renderable (anything without screenSpace: true) as a
group, the same way a GROUP parent works for its children — { x, y }
is the world position mapped to canvas (0, 0), and zoom scales
everything around that same point. It's a function of state, so the
camera can follow something or react to a zoom level you're tracking
yourself.
Your nextState function receives one GameEvent per call: TIME
(frame tick with delta), CLICK, HOVER_IN, HOVER_OUT, MOUSE_MOVE,
MOUSE_LEAVE, or MUSIC_END. The mouse-carrying ones include both
mouse (raw canvas pixels — use for screenSpace/UI logic) and
worldMouse (that same position run through the camera's inverse
transform — use to place/locate world-space things, e.g. build a turret
where the player clicked). With no camera set, worldMouse always
equals mouse.
MOUSE_LEAVE fires when the mouse exits the canvas entirely — mouse/
worldMouse are the position it left from. It also implies a
HOVER_OUT for whatever was hovered at the time, since no further
mousemove inside the canvas (what HOVER_OUT normally rides along
with) can happen once the mouse isn't over it anymore.
runEngine's second type parameter is your own event payload type; give
it one and nextState's event can also be a
{ tag: "CUSTOM", event: YourType }, alongside the built-in events above.
runEngine() resolves with a sendEvent(event) function you call from
anywhere — not just from inside nextState — to deliver it as a CUSTOM
event on a later tick. That's the point: reporting something that
finished outside the normal render-loop-driven flow, like a fetch()
resolving.
type FetchEvent = { status: "done"; body: string } | { status: "failed" };
const { sendEvent } = await runEngine<GameState, FetchEvent>({
initialState,
render,
nextState: ({ state, event }) => {
if (event.tag === "CUSTOM") {
return event.event.status === "done"
? { ...state, result: event.event.body }
: { ...state, result: "failed" };
}
return state;
},
});
fetch("/api/whatever")
.then((res) => res.text())
.then((body) => sendEvent({ status: "done", body }))
.catch(() => sendEvent({ status: "failed" }));See the Custom Events example in the playground for a runnable version of this pattern.
nextState also receives a keyboard map keyed by KeyCode-style keys
(e.g. "KeyW", "ArrowLeft", "Space"), each with isPressed /
isJustPressed / isJustReleased.
Pass a resources map of { src, size, slices } to runEngine to load
spritesheets, then reference them by id with a SPRITE renderable's
resourceId and frame. Set flipX: true to mirror a sprite
horizontally — useful when the art is drawn facing one direction but
needs to move the other way. Add an animations map to a resource —
{ frames: number[], frameDuration, loop } each — to play one with
ANIMATED_SPRITE instead of managing frame by hand.
Pass a sounds map of { src } to runEngine, then call the
playSound(id) function nextState receives to play one, e.g.
playSound("collect") when a cookie is clicked. Calling it again while a
sound is still playing overlaps a new copy instead of cutting the first
one off.
Pass a music map of { src, loop? } to runEngine, then use the
playMusic(id) / pauseMusic() / resumeMusic() functions nextState
receives to control a background track. Unlike playSound, only one
track plays at a time and it keeps running in the background across
frames instead of firing once.
-
playMusic(id)starts a track, or resumes it (from wherever it left off) if it was paused. Calling it with a different id switches tracks. -
pauseMusic()pauses whichever track is current, leaving its position where it left off. -
resumeMusic()resumes whichever track was paused, without needing to still have its id on hand — the same effect as callingplayMusic(id)again. -
setMusicVolume(volume)(0 to 1) controls whichever track is current and whatever plays next. Volume isn't per-track, so switching tracks withplayMusickeeps the volume you last set instead of resetting to full. -
loop(on amusicentry) defaults totrue. Set itfalseon a track to get aMUSIC_ENDevent (carrying that track'sid) once it finishes, instead of having it restart — a looping<audio>element never fires "ended" on its own.
Pass canvas: { width, height, backgroundColor } to runEngine to size
and color the canvas from code. All three are optional; anything you
don't set falls back to the canvas element's existing HTML/CSS.
nextState can also be an array of small NextStateFunctions instead of
one big function. Each one is run in order for every event, and can
return:
- a new state, to update to
-
undefined(or noreturnat all) — no change, but the rest of the list still runs, so a guard can just beif (...) return; -
STOP(imported fromyuuna-engine) — no change, and the rest of the list is skipped for this event, so a shared rule (like "nothing happens once the game is over") only needs to be written once
import { runEngine, STOP, type NextStateFunction } from "yuuna-engine";
const freezeOnGameOver: NextStateFunction<GameState> = ({ state }) => {
if (state.lives <= 0) return STOP;
};
const moveEnemies: NextStateFunction<GameState> = ({ state, event }) => {
if (event.tag === "TIME") {
return { ...state, enemies: move(state.enemies, event.delta) };
}
};
runEngine<GameState>({
initialState,
render,
nextState: [freezeOnGameOver, moveEnemies /* ... */],
});