Service
The `Service` abstract class for custom, story-registered logic shared across scenes, covering actions, serialization, and async handling.
Service is an abstract class for custom actions, behaviors, or any other logic shared across scenes.
To use it, extend it, register a handler for each of its actions in the constructor, and implement serialize and deserialize to save its data with the game.
import {Service} from "narraleaf-react";Example
Here is an example of a custom service that implements a simple gallery.
type GalleryActions = {
add: [name: string];
};
type GalleryData = {
unlocked: string[];
};
class Gallery extends Service<GalleryActions, GalleryData> {
// custom data
unlocked: string[] = [];
constructor() {
super();
// register the action handler; `name` is typed by GalleryActions
this.on("add", (ctx, name) => {
this.unlocked.push(name);
});
}
/* Save the data with the game, and restore it from a save */
serialize(): GalleryData {
return {
unlocked: [...this.unlocked],
};
}
deserialize(data: GalleryData): void {
this.unlocked = [...data.unlocked];
}
/* Custom service logic */
add(name: string) {
// trigger the action
return this.trigger("add", name);
}
}and we can use this service in the game:
const gallery = new Gallery();
story.registerService("gallery", gallery);
myScene.action([
gallery
.add("image1")
.add("image2")
.add("image3"),
]);Creating Custom Service
Implementing Service
To implement a service, extend the Service class. Its two type parameters are the actions it handles and the data it saves:
type MyCustomActions = {
myAction: [arg0: string, arg1: number];
};
type MyCustomData = {
count: number;
};
class MyCustomService extends Service<MyCustomActions, MyCustomData> {
count = 0;
/**
* Serialize the service to data
*
* **Note**: data must be JSON serializable, return null if nothing needs to be saved
*/
serialize(): MyCustomData | null {
return {count: this.count};
}
/**
* Load data to the service
* @param data data returned by serialize
*/
deserialize(data: MyCustomData): void {
this.count = data.count;
}
}serializeruns whenever the game is saved, and whenever a line or a menu is recorded in the backlog, so that the player can return to it. Return a copy of the service's state, not the state itself: the engine keeps whatserializereturns, and a live reference would go on changing with the service.deserializeruns when a save is loaded, with whatserializereturned. A service the save holds no data for - it returnednull, or the save was made before it was registered - is not called, and keeps the state it has.- A service with nothing to save returns
nullfromserializeand leavesdeserializeempty.
Registering Actions
To register actions, use the on method in the constructor. A handler is looked up by name each time its action runs, including after a save is loaded, so it has to be registered before the story starts. An action whose handler is missing logs a warning and does nothing.
class MyCustomService extends Service<MyCustomActions, MyCustomData> {
constructor() {
super();
// ctx, arg0 and arg1 are typed by MyCustomActions
this.on("myAction", (ctx, arg0, arg1) => {
// custom logic
});
}
/* serialize and deserialize */
}For ctx, see ServiceHandlerCtx.
The action map is what type-checks the handlers and the triggers. A service declared without one, extends Service, accepts any action name with any arguments.
Async Actions
A handler that returns a promise holds the story until the promise settles, the way a line of dialogue does. A handler that returns anything else lets the story carry on at once.
this.on("load", async (ctx, url: string) => {
const response = await fetch(url, { signal: ctx.signal });
const data = await response.json();
if (ctx.signal.aborted) return; // cancelled while waiting: leave the game alone
this.data = data;
});If the promise rejects, the story stops at this action and the error is reported.
Since 1.3.0 a handler is waited for whenever it returns a promise. Before, only a function declared async was: a plain function returning a promise - (ctx) => fetch(url) - ran without the story waiting for it, and a promise that rejected left the story waiting forever without reporting anything.
While it holds the story, a handler hears about the player in two ways: it is cancelled when the game moves away from it, and it is asked to hurry when the player skips.
Cancellation
A run is cancelled when the player steps back past it, the story jumps away from it, a Control.any it ran in is won by another branch, or a save is loaded. ctx.signal is then aborted. It is a standard AbortSignal, so it can be handed to fetch and anything else that takes one.
A cancellation cannot be refused, and it does not wait for the handler: by the time the signal fires the game has already moved on. It is the script's responsibility to stop any work still in flight (network requests, timers, animations) and roll back the side effects created so far, so that running the action again after a step back gives exactly the same result. Check ctx.signal.aborted after each await before touching the game again. The rejection a cancelled run ends with - the AbortError of a fetch - is not reported.
Skipping
When the player skips - presses the skip key, holds it, or a host calls skipDialog or fastForward - a handler holding the story is asked to hurry through ctx.skip. Being asked does not end it: the handler decides what being skipped means for it.
| Answer | How | Holding the skip key | fastForward |
|---|---|---|---|
| Finish now | Put what it was doing into its final state and return, in ctx.skip.onRequest | Moves on | Moves on |
| Ignore it | Do nothing - what every handler written before 1.3.0 does | Waits for the handler | Waits up to stepTimeout, then reports "stalled" |
| Refuse | ctx.skip.refuse(message?) | Waits for the handler | Stops at once and reports "refused" |
type MinigameActions = {
countdown: [seconds: number];
fishing: [];
};
class Minigames extends Service<MinigameActions, null> {
constructor() {
super();
// Finish now: skipping ends the countdown at once.
this.on("countdown", async (ctx, seconds) => {
await new Promise<void>(resolve => {
const timer = setTimeout(resolve, seconds * 1000);
ctx.skip.onRequest(() => {
clearTimeout(timer);
resolve();
});
ctx.signal.addEventListener("abort", () => clearTimeout(timer));
});
});
// Refuse: a fast-forward cannot play the minigame for the player, so it stops here.
this.on("fishing", async (ctx) => {
ctx.skip.refuse("waiting for the fishing minigame");
await playFishing(ctx.signal);
});
}
serialize() {
return null;
}
deserialize() {
}
}onRequestis called the first time the player skips, and once more the first time it is in the skip mode (request.forced) - the distinction a line of dialogue draws between one press of the skip key, which reveals a line up to its next pause, and holding it, which walks past the pauses too. It is not called for every repeat of the skip mode.refusereturns a function that withdraws the refusal. Refusals stack: the action can be skipped again once all of them are withdrawn. The message is for people - it is logged and handed back byfastForward- and nothing in the engine reads it.ctx.skip.fastForwardingsays whether the game is fast-forwarding right now, for a handler that can go straight to its final state on entry.- Requests are broadcast, as they are to everything else on the stage: a handler running in a
Control.allbranch or an async one is asked whenever the player skips. A click on the stage is not a request. - Only a handler that returns a promise is ever asked. A synchronous one has finished before the player could ask.
See ServiceHandlerCtx for every member.
ctx.signal and ctx.skip are available since 1.3.0. ctx.onAbort, which fires on the same occasions as ctx.signal, is deprecated.
Trigger Action
To trigger an action, you can use the trigger method.
const service = new MyCustomService();
scene.action([
service.trigger("myAction", "foo", 123)
]);or you can wrap the action in a method.
class MyCustomService extends Service<MyCustomActions, MyCustomData> {
myAction(arg0: string, arg1: number) {
return this.trigger("myAction", arg0, arg1);
}
}
const service = new MyCustomService();
scene.action([
service.myAction("foo", 123),
service
.myAction("foo", 123) // actions are wrapped and chainable
.myAction("bar", 456), // the game will manage the chain behavior automatically
]);Accessing Service
After creating the service, register it with the story under a name. Registering is what puts the service's data in saves, and what lets the interface look it up.
const story = new Story(/* ... */);
story.registerService("gallery", gallery);A component reads it through useLiveGame:
function GalleryPage() {
const liveGame = useLiveGame();
const gallery = liveGame.story?.getService<Gallery>("gallery");
return (
<ul>
{gallery?.unlocked.map((name) => (
<li key={name}>{name}</li>
))}
</ul>
);
}getService throws when no service is registered under the name.
Public Methods
on<K extends StringKeyOf<Content>>
Register an action handler.
class MyCustomService extends Service<MyCustomActions, MyCustomData> {
constructor() {
super();
this.on("myAction", (ctx, arg0, arg1) => {
// custom logic
});
}
}key: K- The action keyhandler: ServiceHandler<Content[K]>- Called with the ServiceHandlerCtx and the action's arguments. A handler that returns a promise holds the story until it settles; see Async Actions- Returns
this
Chainable Methods
trigger<K extends StringKeyOf<Content>>
Trigger an action.
const service = new MyCustomService();
scene.action([
service.trigger("myAction", "foo", 123)
]);key: K- The action key...args: Content[K]- The action arguments