@like2d/scene
v0.1.0
Published
Scene management system for Like2D
Readme
@like2d/like-scene
Scene management system for LÏKE.
Scenes are a modular component of LÏKE based on setting like.handleEvent.
The scene system is simple and powerful, once understood.
It's a bit like running LÏKE inside of LÏKE.
For devs using the built-in callback pattern, scenes can stack functionality on to the current project such as gamepad mapping or debug overlays.
For multi-scene games, they codify a common state-management pattern based on switching between (or nesting) event handler callbacks. It's a lot better than switch-casing on each handler, or manually setting/clearing handler functions on each transition.
Using scenes for your game also replaces the need to pass around global like
or sceneManager wherever it is used.
Getting started
Install like-scene:
npm install @like2d/scene
# or
deno add jsr:@like2d/sceneDo this once to enable scenes:
import { createLike } from "@like2d/like";
import { SceneManager } from "@like2d/scene";
const like = createLike(document.body);
const sceneMan = new SceneManager(like);This will overwrite like.handleEvent with sceneMan.handleEvent, but so far the game behaves as if nothing changed.
This handler serves as a router to calling
handleEvent on the active scene as opposed to like.
This is when we can push our scene:
const myScene: Scene = (like, sceneMan) => {
const frogImage = like.gfx.newImage("tinyfrog.svg");
const scene = {}
scene.draw = function () {
like.gfx.draw(frogImage, [0, 0]);
like.gfx.print("fill", "YOU JUST GOT FROGGED (Q to quit)", [20,20]);
}
scene.keypressed = function (code) {
if (code == "KeyQ") {
sceneManager.pop();
}
}
return scene;
}
sceneMan.push(myScene, true);Notice how scenes have the same structure as the base like event
handler callbacks.
Included scenes
The prefab/ dir contains built-in utility scenes.
startScreen Lets you create a simple click-to-start screen, which can
be useful for defeating autoplay and getting user focus. It's polite.
mapGamepad Is an essential companion to gamepad auto-mapping, which
allows you to bind ambiguous inputs.
fadeTransition Is an example of how to transition between scenes.
It can be used directly, but copy-and-modify is a good idea, too.
Scene Management
Graph pattern
For arbitrary scene management (non stack based), use sceneMan.set() which switches out the stack top.
This is called the "graph" pattern: any scene can transition to
any other.
set also allows you to pass an instance as the second argument, so that preloading becomes possible.
Stack pattern
Use sceneMan.push and sceneMan.pop to manage a scene stack.
It may be logical to lay a game state out with a stack, such as:
title => overworld => battle => battleMenuFor example, we can pop from a battle to get back to overworld (the battle ended),
then we can push a menu from the overworld to enter a new state:
title => overworld => overworldMenuOr, let's say we have a battle test feature on the title screen.
title => battleThe battle doesn't have to know that it was called from the title in order to return
to it. It can simply pop and return to the previous state.
Notice how the function of the stack is not primarily to visually overlay scenes, but to manage logical game state.
However, with sceneMan.get(-2), a scene can see lower scenes and even pass
events to them by setting their own handleEvent callback. This is a generic
approach, and [composing scenes](#Composing scenes) is often preferred.
If using stack, it is wise to push the title screen scene in the root like.load
function so that we can clear the stack and return to it:
while (sceneMan.pop())Otherwise, an empty scene stack without callbacks will result in a broken game.
Stopping the Scene manager
To get rid of scene functionality entirely, simply set it back to default. It is good practice to pop the whole scene stack in order to deinit them all, first.
while (sceneMan.pop());
like.handleEvent = undefined;Preserving handleEvent
The SceneManager overwrites {@link index.LikeHandlers.handleEvent | like.handleEvent} to its own {@link SceneManager.handleEvent}.
The code
const sceneMan = new SceneManager(like);is equivalent to:
const sceneMan = new SceneManager(like, {nobind: true});
like.handleEvent = sceneMan.handleEvent.bind(sceneMan);So if you want to layer middleware onto the scene system, use nobind: true
and connect things as intended.
Save/Load the entire stack
Use one SceneManager per stack and simply switch handleEvent from one to the other.
Understanding Scene lifecycle
A Scene consists of a function that creates a scene instance:
type Scene = (like: Like, scenes: SceneManager) => SceneInstanceWhen we call sceneMan.push or sceneMan.set, the scene is put on the stack without an instance, then instantiated. The scene fuction is called, and then load is fired.
Now, a few things can happen:
If a scene calls sceneMan.pop or sceneMan.set, it will have quit called and subsequently be removed from the stack. If there is no other reference, the scene will be Garbage Collected eventually.
If a scene calles sceneMan.push(newScene, true), it will have quit called and
be unloaded, but reinstantiated when the upper scene is popped. This is good for
resource-heavy scenes that can be safely re-instantiated without losing game state.
If you need the upper and lower scenes to communicate, consider {@link Scene | using composition}
instead. Otherwise, consider storing save data in localStorage.
If a scene calls sceneMan.push(newScene, false), it will neither have quit called
nor be unloaded. However, load will be called when the scene is once again at stack top
(due to the upper scene calling pop).
This is good for overlay scenes, or resource-light scenes made to be resumable.
In the most intense cases (state-heavy AND resource-heavy scenes), an effort will
have to be made: Unload heavy resources before calling pop, and reload them
in load.
Creating your own scenes
Scenes are a function that receives Like and SceneManager
and returns a SceneInstance, which is basically a table of the optional
event handlers of like, sans modules.
Converting from Callbacks
When you first pick up the scene pattern, you might try this:
// Before (callbacks)
like.update = function(dt) { player.update(dt); }
like.draw = () => { player.draw(like.gfx); }
// After (scene)
scenes.set((like, scenes) => {
const scene: SceneInstance = {}
scene.update = function (dt) { player.update(dt); },
scene.draw = () => { player.draw(like.gfx); }
return scene;
});Closure-based scenes
It is reccommended to use a function that returns a Scene, for configurability.
Example:
const myScene = (options: { speed: number }): Scene =>
(like: Like, scenes: SceneManager) => {
const playerImage = like.gfx.newImage('player.png');
let x = 0, y = 0;
return {
update(dt) {
x += options.speed * dt;
},
draw() {
like.gfx.draw(playerImage, [x, y]);
}
mousepressed() {
// exit this scene when user clicks
scene.pop();
}
};
};Class-based scenes
Of course classes are also usable.
class ThingDoer extends SceneInstance {
constructor(like, scenes) {...}
...
}
const thingDoerScene: Scene =
(like, scenes) => new ThingDoer(like, scenes);Or a configurable class:
class ThingDoer extends SceneInstance {
constructor(like, scenes, options) {...}
...
}
const thingDoerScene = (options): SceneEx<ThingDoer> =>
(like, scenes) => new ThingDoer(like, scenes, options);Composing scenes
This is the most powerful scene pattern, and highly reccommended.
Just like the like object, scenes have handleEvent on them.
So, you could layer them like this, for example:
A parent scene contains a child scene, calls it, and handles
lifecycle via instantiate / deinstance.
// Composing scenes lets us know about the children.
// This allows communication, for example:
type UISceneInstance = SceneInstance & {
// Sending events to child scene
buttonClicked(name: string): void;
// Getting info from child scene
getStatus(): string;
};
type UIScene = SceneEx<UISceneInstance>;
const uiScene = (game: UIScene): Scene =>
(like, scenes) => {
const childScene = scenes.instantiate(game);
return {
handleEvent(event) {
// Block mouse events in order to create a top bar.
// Otherwise, propogate them.
const mouseY = like.mouse.getPosition()[1];
if (!event.type.startsWith('mouse') || mouseY > 100) {
// Use likeDispatch so that nested handleEvent can fire,
// if relevant.
likeDispatch(childScene, event);
}
// Then, call my own callbacks.
// Using likeDispatch here will result in an infinite loop.
callOwnHandlers(this, event);
},
mousepressed(pos) {
if (buttonClicked(pos)) {
childScene.buttonClicked('statusbar')
}
},
draw() {
drawStatus(like, childScene.getStatus());
}
};
}
const gameScene = (level: number): UIScene =>
(like, scene) => ({
update() { ... },
draw() { ... },
// mandatory UI methods from interface
buttonClicked(name) {
doSomething(),
},
getStatus() {
return 'all good!';
}
});
like.pushScene(uiScene(gameScene);The main advance of composing scenes versus the stack-overlay technique is that the parent scene knows about its child. Because there's a known interface, the two scenes can communicate.
This makes it perfect for reusable UI, level editors, debug viewers, and more.
Scene stacking
You might assume that the purpose of a scene stack is visual: first push the BG, then the FG, etc.
Actually, composing scenes (above) is a better pattern for that, since it's both explicit and the parent can have a known interface on its child. Here, the upper scene only knows that the lower scene is a scene.
That's the tradeoff. Overlay scenes are good for things like pause screens or gamepad overlays. Anything where the upper doesn't care what the lower is, and where the upper scene should be easily addable/removable.
Using like.getScene(-2), the overlay scene can see
the lower scene and choose how to propagate events.
Remember to call like.push(someScene(), false) in
the lower scene in order to keep its instance alive
for the uuper one.
License
MIT
