Vue 3 Integration
Installation
npm install @skewedaspect/sage-vue@skewedaspect/sage-vue has peer dependencies on vue (3.x) and @skewedaspect/sage.
SageCanvas
SageCanvas renders the canvas, calls createGameEngine, and provides the engine to child components through Vue's dependency injection.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
entityClasses | NodeEntityRegistry | {} | The entity registry the simulation spawns from |
options | SageOptions | {} | Engine configuration passed to createGameEngine() |
engine | GameEngine | none | A pre-created engine. When given, the component neither creates nor disposes one. |
SageCanvas is generic in the entity registry the same way createGameEngine is (Engine). A registry written satisfies NodeEntityRegistry types the engine engine-ready announces by it, so whatever holds that engine outside the UI -- a controller, a game's own setup code -- spawns through it typed against the real keys. useSageEngine, inside the UI, always answers with ObservedEngine regardless of the registry (see below), so this typing matters to the game's own code, not to a component reading the engine back. An engine the game built and typed itself is taken typed as the game typed it.
Events
| Event | Payload | Description |
|---|---|---|
engine-ready | GameEngine | The engine has initialized |
engine-error | unknown | The engine the canvas owns failed to boot, or failed to stop as the canvas unmounted |
Slot props
| Slot prop | Type | Description |
|---|---|---|
loading | boolean | true until the engine finishes initializing |
error | unknown | The error if initialization failed, otherwise null |
engine | GameEngine | null | The engine once ready |
Usage
<template>
<SageCanvas :entity-classes="entityClasses" :options="engineOptions" @engine-ready="onReady">
<template #default="{ loading, error }">
<div v-if="loading" class="loading">Initializing engine...</div>
<div v-else-if="error" class="error">Failed to start: {{ error }}</div>
<GameHud v-else />
</template>
</SageCanvas>
</template>
<!--------------------------------------------------------------------------------------------------------------------->
<style lang="scss" scoped>
.loading, .error {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 1.5rem;
}
</style>
<!--------------------------------------------------------------------------------------------------------------------->
<script setup lang="ts">
import { SageCanvas } from '@skewedaspect/sage-vue';
import type { GameEngine, SageOptions } from '@skewedaspect/sage';
// Components
import GameHud from './gameHud.vue';
// Entities
import { entityClasses } from './entities/index.ts';
const engineOptions : SageOptions = { logLevel: 'info' };
async function onReady(engine : GameEngine) : Promise<void>
{
await engine.managers.levelManager.activateLevel('arena');
await engine.start();
}
</script>
<!--------------------------------------------------------------------------------------------------------------------->The component's overlay div holds the slot content above the canvas. The overlay itself passes pointer events through to the canvas; every element inside it receives them, so HUD buttons and menus are interactive without extra CSS.
On unmount, SageCanvas stops the engine and disposes the BabylonJS engine, unless the engine came in through the engine prop. If the component unmounts while createGameEngine is still in flight, the engine that resolves afterwards is stopped and disposed rather than left running.
A ResizeObserver on the container calls engine.renderEngine.resize() when the container's size changes.
useSageEngine
Answers with the engine the canvas above it provides, in the Ref the canvas holds it in, so a component reads it before there is an engine and sees the one that arrives. Every other composable builds on it.
import { useSageEngine } from '@skewedaspect/sage-vue';
const { engine } = useSageEngine();
// engine is Readonly<Ref<ObservedEngine | null>>engine is typed as ObservedEngine, not GameEngine: the bus, the timer, the bound simulation's getEntity and getEntitiesByTag, and the game manager's registerFrameCallback, which is what a UI reads of an engine and nothing else. Nothing in it needs Babylon, so a host with no render engine, a bench or a test harness that steps a simulation itself, builds one from an EventBus, a GameTimer, its simulation, and a frame callback registry of its own, and provides it under the injection key. An engine typed by any entity registry satisfies it, so a canvas handed a game's own typed engine (through the engine prop) accepts it. A UI that wants that engine's own typed spawn holds the engine the game built rather than reading it back through useSageEngine.
The bus is the engine's, one per engine, and a UI reaches it through engine.value.bus -- there is no separate bus for a UI. Used with no SageCanvas above it, it throws, naming the component.
ObservedEngine.simulation is typed ObservedSimulation, the two entity lookups a UI reads of a simulation, getEntity and getEntitiesByTag. A simulation built from any entity registry satisfies it, which is what lets a canvas take a game's own typed engine.
The injection key
SageCanvas provides its engine ref under SAGE_ENGINE_KEY, and useSageEngine injects it. A component that cannot sit under a SageCanvas, and a test harness standing in for one, provides or injects the key directly:
import { provide, shallowRef } from 'vue';
import { SAGE_ENGINE_KEY } from '@skewedaspect/sage-vue';
import type { ObservedEngine } from '@skewedaspect/sage-vue';
const engine = shallowRef<ObservedEngine | null>(null);
provide(SAGE_ENGINE_KEY, engine);A component under a provider of its own reads that engine, and every composable built on useSageEngine follows it.
useSageEvent
Subscribes to the engine's one bus for as long as the component is mounted, and answers with an unsubscribe:
import { useSageEvent } from '@skewedaspect/sage-vue';
useSageEvent('level:*', (event) =>
{
console.log(event.type, event.payload);
});What it subscribes to is what the bus accepts and nothing else: an event type in the map, or a pattern such as door:* over it. The handler runs from a drain, so a UI hears events while the game is paused. A handler that throws never stops delivery to the rest, and its error leaves whichever drain ran it inside that drain's AggregateError.
A handler hears broadcasts. The bus mints the subscriber ID for a subscription made this way, so a targeted event never reaches one, and nothing a handler returns consumes an event.
useSageTimer
Schedules against the game's own clock -- delay, interval, and cooldown -- with cleanup on unmount. Timers requested before the engine boots are queued and started as soon as it does, so a component can set them up in setup().
import { useSageTimer } from '@skewedaspect/sage-vue';
const { delay, interval, cooldown } = useSageTimer();
const cancel = delay(3000, () => showTutorialHint());delay and interval each answer with a canceller. delay refuses a negative duration and interval refuses one that is not positive, each naming the value it was given. cooldown answers with a pollable CooldownHandle, ready until reset and not ready again until its period of game time has passed; a cooldown asked for before the engine boots answers a stand-in that reads as not ready until the real one exists.
Unmounting cancels every delay and interval the component started and drops every delay and interval still queued for the engine. A cooldown is left alone, since nothing about it runs: one already bound keeps counting down, and one still queued goes on waiting and binds once the engine boots, however long after the component that asked for it is gone.
useSageEntity and useSageEntities
A component reads an entity through a GameEntityView: its id, whether it is present, its state read-only and reactive, and its operations.
<script setup lang="ts">
import { useSageEntity } from '@skewedaspect/sage-vue';
import { PlayerEntity } from './entities/index.ts';
const props = defineProps<{ id : string }>();
const player = useSageEntity(PlayerEntity, props.id);
</script>
<template>
<div v-if="player.present">
Health: {{ player.state.health }}
<button @click="player.ops.heal(10)">Heal</button>
</div>
</template>useSageEntity(entityClass, id) answers with a Ref holding one view, resolved by class and ID. The class both types the view and checks it, so an ID naming an entity of another class leaves the view absent, and a subclass counts.
The ID is read on every refresh, from a plain string, a Ref, or a getter, so a component follows an entity whose ID is known only as the game runs. An ID that changed moves the view to the entity the new one names at the next refresh, and a null ID leaves the view absent, the same answer as an ID naming nothing. The view's own id reads what that last read answered, null included, and the view is the same object throughout: its state keeps the values it last read as the ID changes. A component following a slot another view reads writes the getter form:
import { useSageEntity } from '@skewedaspect/sage-vue';
const player = useSageEntity(PlayerEntity, 'player');
const suit = useSageEntity(SuitEntity, () => player.value.state.suitID);
// Absent while the player wears none, and the suit's own view the moment the slot names one
console.log(suit.value.present, suit.value.state.oxygen);useSageEntities(tag, behaviorClass?) answers with a Ref holding a view per entity carrying the tag, whatever its class, in spawn order. Each view is the same object across refreshes for as long as its entity stays in the collection, so a v-for keyed by id reuses its components. Naming a behavior narrows the collection to the entities holding it, typed to that behavior's state and operations.
The tag is read on every refresh and takes the same three forms. A tag that changed gathers the entities carrying the new one at the next refresh, a null tag gathers nothing, and an entity carrying the old tag and the new one keeps its view across the change.
hasBehavior on a view narrows it to ViewNarrowedTo<Class>, that behavior's slice of state and its operations, the same narrowing hasBehavior on a GameEntity performs inside the simulation.
A member view answers from the last refresh's matched set, not from a fresh lookup by ID. An entity that loses the tag, or the narrowing behavior, leaves its view absent from that refresh on, however long the entity itself stays in the simulation, and ops and hasBehavior answer from the same membership, so both agree with present.
A view refreshes once per rendered frame and keeps refreshing while the simulation is paused, so a pause menu that changes something through an operation shows it. Writing to a view's state does not compile, and at runtime reaches only the view's own copy. The entity-behaviors example's playerStats.vue is written this way.
A view refreshes from the frame loop and from nothing else. A host with no frame loop, one that steps the simulation itself, gets a view that reads its entity once as it is created and holds that reading; such a host reads state straight after the step it ran, through getEntity, rather than through a view.
useSageAction
Answers with a Ref holding the value an action was last delivered with, 0 until it is delivered once:
import { useSageAction } from '@skewedaspect/sage-vue';
const jump = useSageAction('jump');An action reaches a UI the way it reaches an entity, as a broadcast action:<name> event on the engine bus, and the ref reads the raw payload: the boolean of a digital action, or the number of an analog one. A value action updates on every routing pass it is away from rest and once more on the pass it returns to rest; a trigger, a toggle, and a delta each update as they happen, so the ref holds what happened last. A UI that wants the fires themselves, rather than the last value, hears them with useSageEvent.
Reading an action never issues one. A UI drives the simulation by emitting an action of its own or calling an operation.
